@piagent/platform 1.2.13 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +11 -3
  3. package/README.vi.md +90 -0
  4. package/architecture/layers.json +117 -0
  5. package/docs/README.md +23 -0
  6. package/docs/architecture.md +7 -61
  7. package/docs/command-reference-vietnamese.md +6 -6
  8. package/docs/distribution-standard.md +7 -7
  9. package/docs/en/README.md +13 -0
  10. package/docs/en/architecture.md +81 -0
  11. package/docs/en/maintainer-guide.md +121 -0
  12. package/docs/languages.json +40 -0
  13. package/docs/operator-manual-vietnamese.md +7 -7
  14. package/docs/package-architecture-notes.md +9 -0
  15. package/docs/release-install-policy.md +6 -6
  16. package/docs/security-threat-model.md +1 -1
  17. package/docs/team-onboarding.md +2 -2
  18. package/docs/vi/README.md +13 -0
  19. package/docs/vi/architecture.md +81 -0
  20. package/docs/vi/maintainer-guide.md +121 -0
  21. package/docs-site/architecture.html +38 -14
  22. package/docs-site/benchmark.html +3 -1
  23. package/docs-site/commands.html +3 -1
  24. package/docs-site/content/architecture.html +35 -13
  25. package/docs-site/context-engine.html +3 -1
  26. package/docs-site/index.html +3 -1
  27. package/docs-site/mcp-approval.html +3 -1
  28. package/docs-site/mcp-auth.html +3 -1
  29. package/docs-site/mcp-servers.html +3 -1
  30. package/docs-site/mcp.html +3 -1
  31. package/docs-site/permissions.html +3 -1
  32. package/docs-site/profiles.html +3 -1
  33. package/docs-site/quickstart.html +3 -1
  34. package/docs-site/runtime.html +3 -1
  35. package/docs-site/scope.html +3 -1
  36. package/docs-site/security.html +3 -1
  37. package/docs-site/team.html +3 -1
  38. package/docs-site/workflows.html +3 -1
  39. package/package.json +5 -1
  40. package/packages/piagent-core/README.md +6 -2
  41. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  42. package/packages/piagent-core/capabilities/runtime-integrity.js +18 -0
  43. package/packages/piagent-core/extensions/guard-types.ts +0 -37
  44. package/packages/piagent-core/extensions/piagent-guard.ts +170 -2099
  45. package/packages/piagent-core/extensions/task-state.js +22 -3
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +213 -0
  48. package/packages/piagent-core/runtime/hooks/completion-hook.ts +203 -0
  49. package/packages/piagent-core/runtime/hooks/input-hook.ts +122 -0
  50. package/packages/piagent-core/runtime/hooks/session-hooks.ts +104 -0
  51. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +140 -0
  52. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +316 -0
  53. package/packages/piagent-core/runtime/input/chat-images.ts +299 -0
  54. package/packages/piagent-core/runtime/runtime-limits.ts +21 -0
  55. package/packages/piagent-core/runtime/session/message-signals.ts +64 -0
  56. package/packages/piagent-core/runtime/session/runtime-state.ts +175 -0
  57. package/packages/piagent-core/runtime/session/system-prompt.ts +52 -0
  58. package/packages/piagent-core/runtime/session/tool-result-compaction.ts +352 -0
  59. package/packages/piagent-core/runtime/session/usage.ts +178 -0
  60. package/packages/piagent-core/runtime/tools/tool-groups.ts +86 -0
  61. package/packages/piagent-core/runtime/workflows/input-routing.ts +113 -0
  62. package/packages/piagent-core/runtime/workflows/task-intake.ts +73 -0
  63. package/scripts/build-docs-site.mjs +4 -1
  64. package/scripts/check-architecture.mjs +132 -0
  65. package/scripts/check-doc-languages.mjs +67 -0
  66. package/scripts/verify-local.sh +31 -0
@@ -123,7 +123,9 @@ function pickFields(value, allowed) {
123
123
  function legacyRunId(task, sourceName = "task") {
124
124
  const taskId = safeTaskId(task?.taskId ?? sourceName);
125
125
  const identity = `${taskId}\u0000${task?.createdAt ?? "legacy"}\u0000${sourceName}`;
126
- return safeTaskId(`${taskId}-legacy-${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 12)}`);
126
+ const suffix = `legacy-${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
127
+ const taskKey = taskId.slice(0, 80 - suffix.length - 1).replace(/-+$/g, "") || "task";
128
+ return `${taskKey}-${suffix}`;
127
129
  }
128
130
 
129
131
  export function taskContractValidationErrors(input) {
@@ -620,10 +622,27 @@ export function migrateTaskState(cwd, options = {}) {
620
622
  sessionName: belongsToCurrentSession ? options.sessionName : undefined
621
623
  });
622
624
  if (!task) continue;
623
- writeTaskContract(cwd, task);
624
625
  const safeArchiveRoot = ensurePrivateStateDirectory(cwd, archiveRoot, "Task state archive");
625
626
  const archiveTarget = resolveLocalStatePath(cwd, path.join(safeArchiveRoot, entry.name), { label: "Task state archive file" });
626
- fs.renameSync(source, archiveTarget);
627
+ if (fs.existsSync(archiveTarget)) {
628
+ const archived = fs.readFileSync(archiveTarget);
629
+ const legacy = fs.readFileSync(source);
630
+ if (!archived.equals(legacy)) {
631
+ warnings.push(`legacy task archive conflict: ${entry.name}`);
632
+ continue;
633
+ }
634
+ } else {
635
+ fs.copyFileSync(source, archiveTarget, fs.constants.COPYFILE_EXCL);
636
+ fs.chmodSync(archiveTarget, 0o600);
637
+ }
638
+ const written = writeTaskContract(cwd, task);
639
+ const currentTarget = taskRunPath(cwd, written.taskRunId);
640
+ const sourceStat = fs.statSync(source);
641
+ const targetStat = fs.statSync(currentTarget);
642
+ const sameInode = sourceStat.ino > 0 && targetStat.ino > 0
643
+ && sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
644
+ const sameCanonicalPath = fs.realpathSync(source) === fs.realpathSync(currentTarget);
645
+ if (!sameInode && !sameCanonicalPath) fs.rmSync(source);
627
646
  migrated += 1;
628
647
  }
629
648
  return { migrated, current, warnings };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.2.13",
3
+ "version": "1.2.14",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -0,0 +1,213 @@
1
+ import crypto from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import {
5
+ buildContextPack,
6
+ classifyContextTask,
7
+ ensureContextIndexV2,
8
+ estimateContextTokens
9
+ } from "../../extensions/context-engine.js";
10
+ import { matchesProtectedPath } from "../../extensions/policy-core.js";
11
+ import type { TaskContract } from "../../extensions/guard-types.js";
12
+ import { RuntimeSessionState } from "../session/runtime-state.ts";
13
+ import {
14
+ compactManagedProjectInstructions,
15
+ rewriteLegacyProjectInstructions
16
+ } from "../session/system-prompt.ts";
17
+ import { PIAGENT_TOOL_NAMES } from "../tools/tool-groups.ts";
18
+ import { extractTaskRequest, looksLikeGovernedBoilerplate } from "../workflows/input-routing.ts";
19
+ import {
20
+ AUTO_INTAKE_SNAPSHOT_PATTERNS,
21
+ automaticTaskIntakeEligible
22
+ } from "../workflows/task-intake.ts";
23
+
24
+ type RuntimeIntakeResult = {
25
+ started: boolean;
26
+ text: string;
27
+ task?: TaskContract;
28
+ };
29
+
30
+ type AgentStartHookDependencies = {
31
+ state: RuntimeSessionState;
32
+ autoContextEnabled: boolean;
33
+ activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
34
+ readProtectedPaths: (ctx: ExtensionContext) => string[];
35
+ contextExcludePatterns: (ctx: ExtensionContext) => string[];
36
+ promptPackKey: (ctx: ExtensionContext, promptHash: string) => string;
37
+ retrievalKey: (ctx: ExtensionContext, query: string) => string;
38
+ startAutomaticTask: (query: string, ctx: ExtensionContext) => Promise<RuntimeIntakeResult | undefined>;
39
+ telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
40
+ };
41
+
42
+ export function registerAgentStartHook(pi: ExtensionAPI, dependencies: AgentStartHookDependencies): void {
43
+ pi.on("before_agent_start", async (event, ctx) => {
44
+ const projectInstructions = rewriteLegacyProjectInstructions(event.systemPrompt);
45
+ const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
46
+ const signal = classifyContextTask(query);
47
+ const readProtectedPaths = dependencies.readProtectedPaths(ctx);
48
+ const protectedOnlyTarget = signal.paths.length > 0
49
+ && signal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
50
+ const activeTask = dependencies.activeTask(ctx);
51
+ const runtimeIntake = !activeTask && automaticTaskIntakeEligible(query, readProtectedPaths);
52
+ const compactMode = protectedOnlyTarget
53
+ ? "protected"
54
+ : runtimeIntake || activeTask?.intakeMode === "runtime"
55
+ ? "automatic"
56
+ : undefined;
57
+ const compactedInstructions = compactMode
58
+ ? compactManagedProjectInstructions(projectInstructions.systemPrompt, compactMode)
59
+ : { systemPrompt: projectInstructions.systemPrompt, compacted: false };
60
+ const effectiveSystemPrompt = compactedInstructions.systemPrompt;
61
+ const systemPromptUpdate = effectiveSystemPrompt !== event.systemPrompt
62
+ ? { systemPrompt: effectiveSystemPrompt }
63
+ : undefined;
64
+ const active = new Set<string>(pi.getActiveTools() as string[]);
65
+ const toolMetadata = pi.getAllTools()
66
+ .filter((tool) => active.has(tool.name))
67
+ .map((tool) => ({
68
+ name: tool.name,
69
+ description: tool.description,
70
+ parameters: tool.parameters,
71
+ promptGuidelines: tool.promptGuidelines
72
+ }));
73
+ const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
74
+ const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
75
+ const autoPackUseful = activeTask?.trace.outcome !== "pending"
76
+ && (runtimeIntake || signal.paths.length === 0);
77
+ dependencies.telemetry(ctx, {
78
+ event: "agent_prompt",
79
+ promptHash: signal.promptHash,
80
+ promptChars: signal.promptChars,
81
+ workflow: signal.workflow,
82
+ riskLane: signal.lane,
83
+ activeTools: active.size,
84
+ activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
85
+ toolSchemaTokens,
86
+ systemPromptTokens,
87
+ systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
88
+ legacyProjectInstructionsRewritten: projectInstructions.rewritten,
89
+ managedInstructionsCompacted: compactedInstructions.compacted,
90
+ contextUsage: ctx.getContextUsage()
91
+ });
92
+
93
+ const finishAgentStart = async (contextMessage?: {
94
+ customType: string;
95
+ content: string;
96
+ details: Record<string, unknown>;
97
+ }) => {
98
+ const intake = await dependencies.startAutomaticTask(query, ctx);
99
+ if (!contextMessage && !intake) return systemPromptUpdate;
100
+ const content = [contextMessage?.content, intake?.text].filter(Boolean).join("\n\n");
101
+ return {
102
+ ...(systemPromptUpdate ?? {}),
103
+ message: {
104
+ customType: contextMessage?.customType ?? "piagent-runtime-task-intake",
105
+ content,
106
+ display: false,
107
+ details: {
108
+ ...(contextMessage?.details ?? {}),
109
+ runtimeTask: intake?.task
110
+ ? {
111
+ taskId: intake.task.taskId,
112
+ taskRunId: intake.task.taskRunId,
113
+ scope: intake.task.scope,
114
+ verifyCommands: intake.task.verifyCommands,
115
+ intakeMode: intake.task.intakeMode
116
+ }
117
+ : undefined,
118
+ runtimeIntakeStarted: intake?.started ?? false
119
+ }
120
+ }
121
+ };
122
+ };
123
+
124
+ const packKey = dependencies.promptPackKey(ctx, signal.promptHash);
125
+ if (
126
+ query.length < 20
127
+ || signal.workflow === "usage"
128
+ || !dependencies.autoContextEnabled
129
+ || !autoPackUseful
130
+ || dependencies.state.hasAutoPackedPrompt(packKey)
131
+ ) {
132
+ return finishAgentStart();
133
+ }
134
+
135
+ dependencies.state.rememberAutoPackedPrompt(packKey);
136
+ try {
137
+ const excludePatterns = dependencies.contextExcludePatterns(ctx);
138
+ const ensured = await ensureContextIndexV2(ctx.cwd, {
139
+ excludePatterns,
140
+ rebuildMissing: false
141
+ });
142
+ const status = ensured.status;
143
+ if (!status.exists || status.stale) {
144
+ dependencies.telemetry(ctx, {
145
+ event: "context_pack",
146
+ queryHash: signal.promptHash,
147
+ confidence: "none",
148
+ candidates: 0,
149
+ selected: 0,
150
+ skipped: status.exists ? "stale-index" : "missing-index"
151
+ });
152
+ return finishAgentStart();
153
+ }
154
+ const budgetTokens = runtimeIntake
155
+ ? 900
156
+ : signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
157
+ const pack = await buildContextPack(ctx.cwd, query, {
158
+ budgetTokens,
159
+ includeCode: runtimeIntake,
160
+ includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
161
+ currentSnapshot: runtimeIntake,
162
+ limit: runtimeIntake ? 8 : 12,
163
+ excludePatterns
164
+ });
165
+ dependencies.telemetry(ctx, {
166
+ event: "context_pack",
167
+ queryHash: pack.queryHash,
168
+ confidence: pack.confidence,
169
+ candidates: pack.candidates,
170
+ selected: pack.selected.length,
171
+ estimatedTokens: pack.estimatedTokens,
172
+ selectedPaths: pack.selected.map((item) => item.path),
173
+ finderRecommended: pack.finderRecommended,
174
+ currentSnapshot: runtimeIntake
175
+ });
176
+ if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return finishAgentStart();
177
+ dependencies.state.rememberInjectedContextPack(ctx, dependencies.retrievalKey(ctx, query), {
178
+ queryHash: pack.queryHash,
179
+ confidence: pack.confidence,
180
+ estimatedTokens: pack.estimatedTokens,
181
+ paths: pack.selected.map((item) => item.path)
182
+ });
183
+ for (const selected of pack.selected) {
184
+ dependencies.state.rememberObservedContext(ctx, {
185
+ path: selected.path,
186
+ reason: "Runtime injected a bounded Context Engine navigation pack."
187
+ });
188
+ }
189
+ return finishAgentStart({
190
+ customType: "piagent-context-pack-v2",
191
+ content: pack.text,
192
+ details: {
193
+ schemaVersion: 2,
194
+ queryHash: pack.queryHash,
195
+ confidence: pack.confidence,
196
+ estimatedTokens: pack.estimatedTokens,
197
+ paths: pack.selected.map((item) => item.path),
198
+ currentSnapshot: runtimeIntake
199
+ }
200
+ });
201
+ } catch (error) {
202
+ dependencies.telemetry(ctx, {
203
+ event: "context_pack",
204
+ queryHash: signal.promptHash,
205
+ confidence: "none",
206
+ candidates: 0,
207
+ selected: 0,
208
+ error: error instanceof Error ? error.message : String(error)
209
+ });
210
+ return finishAgentStart();
211
+ }
212
+ });
213
+ }
@@ -0,0 +1,203 @@
1
+ import crypto from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import type { TaskContract } from "../../extensions/guard-types.js";
5
+ import { runtimeLifecycleMode, workingTreeEvidenceDigest } from "../../extensions/task-lifecycle.js";
6
+ import { workingTreeSnapshot } from "../../extensions/task-state.js";
7
+ import {
8
+ assistantMessageHasToolCall,
9
+ assistantMessageText,
10
+ looksLikeCompletionClaim,
11
+ looksLikeIncompleteHandoff
12
+ } from "../session/message-signals.ts";
13
+ import { RuntimeSessionState } from "../session/runtime-state.ts";
14
+ import type { ObservedTaskContext } from "../session/runtime-state.ts";
15
+
16
+ type CompletionGate = {
17
+ decision: "pass" | "fail";
18
+ missing: string[];
19
+ missingVerifyCommands: string[];
20
+ };
21
+
22
+ type CompletionHookDependencies = {
23
+ state: RuntimeSessionState;
24
+ maxManifestFiles: number;
25
+ autoRecoveryEnabled: boolean;
26
+ activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
27
+ flushObservedTaskContext: (
28
+ pi: ExtensionAPI,
29
+ ctx: ExtensionContext,
30
+ pendingContext: ObservedTaskContext[],
31
+ maxManifestFiles: number,
32
+ event: string
33
+ ) => TaskContract | undefined;
34
+ completionProjection: (
35
+ cwd: string,
36
+ task: TaskContract,
37
+ currentDigests: Record<string, string>
38
+ ) => TaskContract;
39
+ evaluateGate: (
40
+ cwd: string,
41
+ task: TaskContract,
42
+ currentDigests: Record<string, string>,
43
+ currentDigest: string
44
+ ) => CompletionGate;
45
+ writeTask: (cwd: string, task: TaskContract) => TaskContract;
46
+ activateBaseTools: (ctx: ExtensionContext) => unknown;
47
+ appendTrace: (cwd: string, payload: Record<string, unknown>) => void;
48
+ appendSessionTrace: (pi: ExtensionAPI, payload: Record<string, unknown>) => void;
49
+ telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
50
+ finalGateMode: (ctx: ExtensionContext) => string;
51
+ verifierInstructions: (commands: string[]) => string[];
52
+ };
53
+
54
+ export function registerCompletionHook(pi: ExtensionAPI, dependencies: CompletionHookDependencies): void {
55
+ const {
56
+ state,
57
+ maxManifestFiles,
58
+ autoRecoveryEnabled,
59
+ activeTask,
60
+ flushObservedTaskContext,
61
+ completionProjection,
62
+ evaluateGate,
63
+ writeTask,
64
+ activateBaseTools,
65
+ appendTrace,
66
+ appendSessionTrace,
67
+ telemetry,
68
+ finalGateMode,
69
+ verifierInstructions
70
+ } = dependencies;
71
+
72
+ pi.on("message_end", async (event, ctx) => {
73
+ if (!event.message || event.message.role !== "assistant" || assistantMessageHasToolCall(event.message)) return;
74
+ let task = flushObservedTaskContext(
75
+ pi,
76
+ ctx,
77
+ state.observedContext(ctx),
78
+ maxManifestFiles,
79
+ "context_observed_before_handoff"
80
+ ) ?? activeTask(ctx);
81
+ if (!task) return;
82
+ const text = assistantMessageText(event.message);
83
+ const completionClaim = looksLikeCompletionClaim(text);
84
+ const incompleteHandoff = looksLikeIncompleteHandoff(text);
85
+ if (task.trace.outcome !== "pending") return;
86
+ const readOnlyEvidenceObserved = task.changeMode === "read-only" && task.contextManifest.length > 0;
87
+ const potentiallyFinalEvidence = task.observedChangedFiles.length > 0
88
+ || task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)
89
+ || readOnlyEvidenceObserved;
90
+ if (!completionClaim && (incompleteHandoff || !potentiallyFinalEvidence)) return;
91
+
92
+ const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
93
+ const currentDigest = workingTreeEvidenceDigest(currentDigests);
94
+ const currentPassingVerifierObserved = task.verifyEvidence.some((evidence) => (
95
+ evidence.exitCode === 0
96
+ && evidence.observed === true
97
+ && evidence.matchedProfileCommand === true
98
+ && evidence.workingTreeDigest === currentDigest
99
+ ));
100
+ const handoffAttempt = completionClaim || (
101
+ !incompleteHandoff
102
+ && (task.observedChangedFiles.length > 0 || currentPassingVerifierObserved || readOnlyEvidenceObserved)
103
+ );
104
+ let completionGate: CompletionGate | undefined;
105
+ if (handoffAttempt) {
106
+ const projected = completionProjection(ctx.cwd, task, currentDigests);
107
+ const projectedGate = evaluateGate(ctx.cwd, projected, currentDigests, currentDigest);
108
+ completionGate = projectedGate;
109
+ if (projectedGate.decision === "pass") {
110
+ task = writeTask(ctx.cwd, projected);
111
+ state.cacheTaskIdentity(ctx, task);
112
+ state.clearCompletionRecoveryAttempt(task.taskRunId);
113
+ state.clearObservedContext(ctx);
114
+ activateBaseTools(ctx);
115
+ const trace = {
116
+ event: "task_auto_completed",
117
+ taskId: task.taskId,
118
+ taskRunId: task.taskRunId,
119
+ sessionId: task.sessionId,
120
+ changedFiles: task.changedFiles,
121
+ lifecycleMode: runtimeLifecycleMode(task)
122
+ };
123
+ appendTrace(ctx.cwd, trace);
124
+ appendSessionTrace(pi, trace);
125
+ telemetry(ctx, trace);
126
+ return;
127
+ }
128
+ }
129
+ if (!handoffAttempt || finalGateMode(ctx) !== "enforce") return;
130
+ const gate = completionGate ?? evaluateGate(ctx.cwd, task, currentDigests, currentDigest);
131
+ if (gate.decision === "pass") return;
132
+
133
+ const recoveryAttempt = state.completionRecoveryAttempt(task.taskRunId);
134
+ const lifecycleMode = runtimeLifecycleMode(task);
135
+ if (autoRecoveryEnabled && lifecycleMode !== "manual" && recoveryAttempt < 1) {
136
+ state.rememberCompletionRecoveryAttempt(task.taskRunId, recoveryAttempt + 1);
137
+ const recoveryGuidance = task.changeMode === "read-only"
138
+ ? [
139
+ "Continue the same read-only task. Review the cited evidence against the requested scope, state concrete unknowns, and complete the returned review step when required.",
140
+ "Do not mutate project source or repeat diagnostic Piagent tools; use ordinary targeted reads and the active progress tool only."
141
+ ]
142
+ : [
143
+ "Continue the same bounded task. Re-check the requested behavior against current source, make a relevant in-scope change when required, and run every exact configured verifier.",
144
+ ...verifierInstructions(gate.missingVerifyCommands),
145
+ "A pre-existing passing test without a relevant source diff is not completion. Do not repeat diagnostic Piagent tools; use ordinary read/edit/bash work."
146
+ ];
147
+ const recovery = [
148
+ "[Piagent continuation required]",
149
+ `Task ${task.taskId} cannot finish yet. Missing: ${gate.missing.join(", ")}.`,
150
+ ...recoveryGuidance
151
+ ].join("\n");
152
+ pi.sendMessage(
153
+ { customType: "piagent-completion-recovery", content: recovery, display: false, details: { taskId: task.taskId, missing: gate.missing } },
154
+ { deliverAs: "followUp", triggerTurn: true }
155
+ );
156
+ const recoveryTrace = {
157
+ event: "completion_recovery_scheduled",
158
+ taskId: task.taskId,
159
+ taskRunId: task.taskRunId,
160
+ sessionId: task.sessionId,
161
+ attempt: recoveryAttempt + 1,
162
+ missing: gate.missing
163
+ };
164
+ appendTrace(ctx.cwd, recoveryTrace);
165
+ appendSessionTrace(pi, recoveryTrace);
166
+ telemetry(ctx, recoveryTrace);
167
+ const continuingNotice = [
168
+ "[Piagent completion gate: CONTINUING]",
169
+ `Task ${task.taskId} needs one bounded correction pass before handoff.`,
170
+ ""
171
+ ].join("\n");
172
+ const content = Array.isArray(event.message.content)
173
+ ? [{ type: "text" as const, text: continuingNotice }, ...event.message.content]
174
+ : [{ type: "text" as const, text: `${continuingNotice}${text}` }];
175
+ return { message: { ...event.message, content } };
176
+ }
177
+
178
+ const notice = [
179
+ "[Piagent completion gate: NOT APPROVED]",
180
+ `Task ${task.taskId} (${task.taskRunId}) is still open.`,
181
+ `Missing: ${gate.missing.join(", ") || "a completed task trace"}.`,
182
+ ...verifierInstructions(gate.missingVerifyCommands),
183
+ "The response below is preserved as work in progress and must not be treated as a completion report.",
184
+ ""
185
+ ].join("\n");
186
+ const content = Array.isArray(event.message.content)
187
+ ? [{ type: "text" as const, text: notice }, ...event.message.content]
188
+ : [{ type: "text" as const, text: `${notice}${text}` }];
189
+ const trace = {
190
+ event: "completion_claim_blocked",
191
+ taskId: task.taskId,
192
+ taskRunId: task.taskRunId,
193
+ sessionId: task.sessionId,
194
+ missing: gate.missing,
195
+ responseHash: crypto.createHash("sha256").update(text).digest("hex"),
196
+ responseChars: text.length
197
+ };
198
+ appendTrace(ctx.cwd, trace);
199
+ appendSessionTrace(pi, trace);
200
+ telemetry(ctx, trace);
201
+ return { message: { ...event.message, content } };
202
+ });
203
+ }
@@ -0,0 +1,122 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { classifyContextTask } from "../../extensions/context-engine.js";
4
+ import { matchesProtectedPath } from "../../extensions/policy-core.js";
5
+ import type { TaskContract } from "../../extensions/guard-types.js";
6
+ import { attachLocalImagesFromText } from "../input/chat-images.ts";
7
+ import type { ChatImageAccessPolicy } from "../input/chat-images.ts";
8
+ import { LONG_INPUT_CHARS } from "../runtime-limits.ts";
9
+ import { buildContextPreflight, buildUsageSnapshot } from "../session/usage.ts";
10
+ import { activeTaskToolGroups, toolGroupsForPrompt } from "../tools/tool-groups.ts";
11
+ import type { PiagentToolGroup } from "../tools/tool-groups.ts";
12
+ import {
13
+ buildFreshCommand,
14
+ chooseFreshWorkflow,
15
+ extractTaskRequest,
16
+ isFreshOrUtilityInput,
17
+ isPiagentWorkflowInput,
18
+ looksLikeGovernedBoilerplate,
19
+ trimTaskForInline
20
+ } from "../workflows/input-routing.ts";
21
+ import {
22
+ automaticTaskIntakeEligible,
23
+ manualTaskIntakeEligible
24
+ } from "../workflows/task-intake.ts";
25
+
26
+ type InputHookDependencies = {
27
+ boilerplateCollapseChars: number;
28
+ activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
29
+ readProtectedPaths: (ctx: ExtensionContext) => string[];
30
+ imageAccess: (ctx: ExtensionContext) => ChatImageAccessPolicy;
31
+ activateToolGroups: (ctx: ExtensionContext, groups: PiagentToolGroup[]) => unknown;
32
+ telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
33
+ };
34
+
35
+ export function registerInputHook(pi: ExtensionAPI, dependencies: InputHookDependencies): void {
36
+ pi.on("input", async (event, ctx) => {
37
+ const text = event.text.trim();
38
+ if (!text || isFreshOrUtilityInput(text)) return { action: "continue" };
39
+ if (/^\/piagent-workflow\b/i.test(text)) {
40
+ return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
41
+ }
42
+
43
+ const taskSignal = classifyContextTask(text);
44
+ const activeTask = dependencies.activeTask(ctx);
45
+ const readProtectedPaths = dependencies.readProtectedPaths(ctx);
46
+ const protectedTarget = taskSignal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
47
+ const protectedOnlyTarget = taskSignal.paths.length > 0
48
+ && taskSignal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
49
+ const runtimeIntake = !activeTask && automaticTaskIntakeEligible(text, readProtectedPaths);
50
+ const manualIntake = !activeTask && !runtimeIntake && manualTaskIntakeEligible(text, readProtectedPaths);
51
+ const promptGroups = protectedOnlyTarget
52
+ ? []
53
+ : toolGroupsForPrompt(text).filter((group) => (
54
+ runtimeIntake ? group !== "intake" && group !== "task" : true
55
+ ));
56
+ if (manualIntake && !promptGroups.includes("intake")) promptGroups.push("intake");
57
+ dependencies.activateToolGroups(ctx, activeTask?.trace.outcome === "pending"
58
+ ? [...promptGroups.filter((group) => group !== "intake"), ...activeTaskToolGroups(activeTask)]
59
+ : promptGroups);
60
+ dependencies.telemetry(ctx, {
61
+ event: "user_input",
62
+ source: event.source,
63
+ promptHash: taskSignal.promptHash,
64
+ promptChars: taskSignal.promptChars,
65
+ workflow: taskSignal.workflow,
66
+ riskLane: taskSignal.lane,
67
+ intakeMode: runtimeIntake ? "runtime" : "model",
68
+ protectedTarget,
69
+ protectedOnlyTarget,
70
+ manualIntake,
71
+ explicitPathCount: taskSignal.paths.length,
72
+ termCount: taskSignal.terms.length
73
+ });
74
+
75
+ const imageAttachment = attachLocalImagesFromText(
76
+ text,
77
+ event.images,
78
+ ctx.cwd,
79
+ () => dependencies.imageAccess(ctx)
80
+ );
81
+ if (imageAttachment?.attached.length) {
82
+ ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
83
+ } else if (imageAttachment?.skipped.length) {
84
+ ctx.ui.notify(`Piagent image input: skipped ${imageAttachment.skipped.length} local image path(s)`, "warning");
85
+ }
86
+
87
+ const inputText = imageAttachment?.text ?? text;
88
+ const canRewriteWorkflow = event.source !== "extension";
89
+ const snapshot = buildUsageSnapshot(ctx, String(pi.getThinkingLevel()));
90
+ const preflight = buildContextPreflight(snapshot, chooseFreshWorkflow(inputText, inputText), inputText.length);
91
+ const hasBoilerplate = looksLikeGovernedBoilerplate(inputText);
92
+ const shouldFreshen = canRewriteWorkflow
93
+ && preflight.recommendation === "fresh-session"
94
+ && (hasBoilerplate || isPiagentWorkflowInput(inputText) || inputText.length >= LONG_INPUT_CHARS);
95
+ const shouldCollapseBoilerplate = canRewriteWorkflow
96
+ && hasBoilerplate
97
+ && inputText.length >= dependencies.boilerplateCollapseChars;
98
+ const outgoingImages = [
99
+ ...(Array.isArray(event.images) ? event.images : []),
100
+ ...(imageAttachment?.images ?? [])
101
+ ];
102
+
103
+ if (!shouldFreshen && !shouldCollapseBoilerplate) {
104
+ if (imageAttachment?.attached.length) return { action: "transform", text: inputText, images: outgoingImages };
105
+ return { action: "continue" };
106
+ }
107
+
108
+ const task = extractTaskRequest(inputText);
109
+ const workflow = chooseFreshWorkflow(inputText, task);
110
+ const reason = shouldFreshen
111
+ ? "Current session is near context limits; use a fresh governed session."
112
+ : "Mandatory flow boilerplate is already part of the platform; collapse it to the task request.";
113
+ const command = shouldFreshen
114
+ ? buildFreshCommand(ctx.cwd, workflow, inputText, reason)
115
+ : `/${workflow} ${trimTaskForInline(task)}`;
116
+
117
+ ctx.ui.notify(`Piagent preflight: ${reason}`, "warning");
118
+ return outgoingImages.length > 0
119
+ ? { action: "transform", text: command, images: outgoingImages }
120
+ : { action: "transform", text: command };
121
+ });
122
+ }