@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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/CHANGELOG.md +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — mechanical commit ledger + host-observed provenance.
|
|
3
|
+
*
|
|
4
|
+
* Direct commits are observed by the private session Git shim (item 2c971806).
|
|
5
|
+
* The older command/output recognizer remains as a conservative fallback. Neither
|
|
6
|
+
* path blocks Git, parses Bash policy, or grants authority. The shim tags a plain
|
|
7
|
+
* commit with the one claimed task's reference (item df71ab6c, owner-approved),
|
|
8
|
+
* and each commit's outcome is told to the model under the command's output.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { resolveToken, resolveEndpoint } from "./core/auth-store.ts";
|
|
15
|
+
import { getBond } from "./core/bond-state.ts";
|
|
16
|
+
import {
|
|
17
|
+
DevspecAuthError,
|
|
18
|
+
DevspecRateLimitError,
|
|
19
|
+
callDevspec,
|
|
20
|
+
isRecoverableTransportError,
|
|
21
|
+
type CallOptions,
|
|
22
|
+
} from "./core/client.ts";
|
|
23
|
+
import {
|
|
24
|
+
annotateCommitMessage,
|
|
25
|
+
captureGitCommit,
|
|
26
|
+
recordObservedCommit,
|
|
27
|
+
repoCommitMessage,
|
|
28
|
+
repoResolver,
|
|
29
|
+
type LedgerCommit,
|
|
30
|
+
} from "./core/commit-ledger.ts";
|
|
31
|
+
import { debugRemote } from "./core/debug.ts";
|
|
32
|
+
import { unwrapMcpResult } from "./core/result.ts";
|
|
33
|
+
import {
|
|
34
|
+
drainGitObservations,
|
|
35
|
+
initializeGitObserver,
|
|
36
|
+
prepareGitObservation,
|
|
37
|
+
shutdownGitObserver,
|
|
38
|
+
type FrozenGitObservationEnvelope,
|
|
39
|
+
type ObservedGitCommit,
|
|
40
|
+
} from "./core/git-observer.ts";
|
|
41
|
+
import { resolveGitRemote, resolveProjectPin, sanitizeGitRemoteForTransport } from "./core/project-scope.ts";
|
|
42
|
+
import { commitStampNote, commitStampScope, type CommitStampDeps } from "./core/commit-stamp.ts";
|
|
43
|
+
import { listObservedClaims } from "./core/work-state.ts";
|
|
44
|
+
import { resolveNudgeProject } from "./devspec-provenance-assistance.ts";
|
|
45
|
+
|
|
46
|
+
function commandOf(args: unknown): string {
|
|
47
|
+
const a = args as { command?: unknown } | null;
|
|
48
|
+
return typeof a?.command === "string" ? a.command : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function outputOf(result: unknown): string {
|
|
52
|
+
const r = result as { content?: Array<{ type?: string; text?: string }> } | null;
|
|
53
|
+
if (!r || !Array.isArray(r.content)) return "";
|
|
54
|
+
return r.content
|
|
55
|
+
.filter((p) => p && p.type === "text" && typeof p.text === "string")
|
|
56
|
+
.map((p) => p.text as string)
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
61
|
+
const FULL_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
62
|
+
const REPORT_QUEUE_KEY = "__devspecPiCommitReportQueue";
|
|
63
|
+
const MAX_REPORTS = 100;
|
|
64
|
+
const REPORT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
export interface FrozenCommitAttribution {
|
|
67
|
+
connectionId: string;
|
|
68
|
+
projectId: string;
|
|
69
|
+
/** Hidden transport capability frozen host-side; never exported to the shim. */
|
|
70
|
+
connectionCapability?: string;
|
|
71
|
+
observationId?: string;
|
|
72
|
+
/** Captured before Bash; hashes only, no bearer passed to child processes. */
|
|
73
|
+
transport?: { tokenHash: string; endpoint: string };
|
|
74
|
+
/** Captured once when the proved observation is enqueued, not on retry. */
|
|
75
|
+
repository?: { remote: string | null; pin: string | null };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ReporterDeps {
|
|
79
|
+
resolveToken(): Promise<{ token: string } | null>;
|
|
80
|
+
resolveEndpoint(): Promise<string>;
|
|
81
|
+
resolveGitRemote(cwd: string): Promise<string | null>;
|
|
82
|
+
resolveProjectPin(cwd: string): Promise<string | null>;
|
|
83
|
+
call(
|
|
84
|
+
token: string,
|
|
85
|
+
name: string,
|
|
86
|
+
args: Record<string, unknown>,
|
|
87
|
+
options?: CallOptions,
|
|
88
|
+
): Promise<unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const reporterDeps: ReporterDeps = {
|
|
92
|
+
resolveToken,
|
|
93
|
+
resolveEndpoint,
|
|
94
|
+
resolveGitRemote,
|
|
95
|
+
resolveProjectPin,
|
|
96
|
+
call: callDevspec,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type CommitReportOutcome = "sent" | "permanent_rejection" | "transient_failure";
|
|
100
|
+
|
|
101
|
+
export function commitTokenHash(token: string): string {
|
|
102
|
+
return createHash("sha256").update(token).digest("hex");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Capture host auth before execution. A later login cannot adopt its observations. */
|
|
106
|
+
export async function captureCommitTransport(deps: ReporterDeps = reporterDeps): Promise<FrozenCommitAttribution["transport"] | null> {
|
|
107
|
+
try {
|
|
108
|
+
const [auth, endpoint] = await Promise.all([deps.resolveToken(), deps.resolveEndpoint()]);
|
|
109
|
+
if (!auth?.token || !endpoint) return null;
|
|
110
|
+
return Object.freeze({ tokenHash: commitTokenHash(auth.token), endpoint });
|
|
111
|
+
} catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Freeze actual observed-worktree scope once; no shell parsing or cwd guessing. */
|
|
115
|
+
export async function captureCommitRepository(cwd: string, projectId: string, deps: ReporterDeps = reporterDeps): Promise<FrozenCommitAttribution["repository"] | null> {
|
|
116
|
+
try {
|
|
117
|
+
const [rawPin, rawRemote] = await Promise.all([deps.resolveProjectPin(cwd), deps.resolveGitRemote(cwd)]);
|
|
118
|
+
const remote = sanitizeGitRemoteForTransport(rawRemote);
|
|
119
|
+
if (rawRemote && !remote) return null;
|
|
120
|
+
if (rawPin && rawPin !== projectId) return null;
|
|
121
|
+
const pin = rawPin === projectId ? rawPin : null;
|
|
122
|
+
return remote || pin ? Object.freeze({ remote, pin }) : null;
|
|
123
|
+
} catch { return null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Report one mechanically proved commit using identity frozen before execution. */
|
|
127
|
+
export async function reportCapturedCommit(
|
|
128
|
+
commit: LedgerCommit,
|
|
129
|
+
cwd: string,
|
|
130
|
+
attribution: FrozenCommitAttribution,
|
|
131
|
+
deps: ReporterDeps = reporterDeps,
|
|
132
|
+
): Promise<CommitReportOutcome> {
|
|
133
|
+
if (!FULL_SHA.test(commit.sha) || !FULL_PROJECT_ID.test(attribution.connectionId) || !FULL_PROJECT_ID.test(attribution.projectId) ||
|
|
134
|
+
!attribution.connectionCapability || !attribution.transport || !attribution.repository) {
|
|
135
|
+
// Old reload-surviving entries cannot acquire today's missing binding.
|
|
136
|
+
return "permanent_rejection";
|
|
137
|
+
}
|
|
138
|
+
const resolved = await deps.resolveToken().catch(() => null);
|
|
139
|
+
if (!resolved?.token) return "transient_failure";
|
|
140
|
+
const endpoint = await deps.resolveEndpoint().catch(() => null);
|
|
141
|
+
if (!endpoint) return "transient_failure";
|
|
142
|
+
if (commitTokenHash(resolved.token) !== attribution.transport.tokenHash || endpoint !== attribution.transport.endpoint) return "permanent_rejection";
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const repository = await captureCommitRepository(cwd, attribution.projectId, deps);
|
|
146
|
+
if (!repository || repository.remote !== attribution.repository.remote || repository.pin !== attribution.repository.pin) return "permanent_rejection";
|
|
147
|
+
const { remote, pin } = attribution.repository;
|
|
148
|
+
|
|
149
|
+
const response = await deps.call(
|
|
150
|
+
resolved.token,
|
|
151
|
+
"report_commit_provenance",
|
|
152
|
+
{
|
|
153
|
+
connection_id: attribution.connectionId,
|
|
154
|
+
commit_sha: commit.sha,
|
|
155
|
+
branch: commit.branch,
|
|
156
|
+
...(attribution.observationId ? { observation_id: attribution.observationId } : {}),
|
|
157
|
+
...(remote ? { git_remote: remote } : {}),
|
|
158
|
+
...(!remote && pin ? { pinned_project_id: pin } : {}),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
lane: "work",
|
|
162
|
+
timeoutMs: 10_000,
|
|
163
|
+
retry: "never",
|
|
164
|
+
endpoint: attribution.transport.endpoint,
|
|
165
|
+
connectionCapability: attribution.connectionCapability,
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
const result = unwrapMcpResult(response);
|
|
169
|
+
const details = result.details as { recorded?: unknown; conflict?: unknown } | null;
|
|
170
|
+
if (result.isError || details?.recorded === false || details?.conflict === true) return "permanent_rejection";
|
|
171
|
+
return details?.recorded === true ? "sent" : "transient_failure";
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof DevspecRateLimitError || error instanceof DevspecAuthError || isRecoverableTransportError(error)) {
|
|
174
|
+
return "transient_failure";
|
|
175
|
+
}
|
|
176
|
+
return "permanent_rejection";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface QueuedReport {
|
|
181
|
+
key: string;
|
|
182
|
+
commit: LedgerCommit;
|
|
183
|
+
cwd: string;
|
|
184
|
+
attribution: FrozenCommitAttribution;
|
|
185
|
+
createdAt: number;
|
|
186
|
+
attempts: number;
|
|
187
|
+
nextAttemptAt: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ReportQueueStore {
|
|
191
|
+
items: QueuedReport[];
|
|
192
|
+
flushing: Promise<void> | null;
|
|
193
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reportQueue(): ReportQueueStore {
|
|
197
|
+
const g = globalThis as unknown as Record<string, ReportQueueStore | undefined>;
|
|
198
|
+
if (!g[REPORT_QUEUE_KEY]) g[REPORT_QUEUE_KEY] = { items: [], flushing: null, retryTimer: null };
|
|
199
|
+
return g[REPORT_QUEUE_KEY] as ReportQueueStore;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function enqueueCommitReport(commit: LedgerCommit, cwd: string, attribution: FrozenCommitAttribution): void {
|
|
203
|
+
const queue = reportQueue();
|
|
204
|
+
const key = `${attribution.connectionId}:${commit.sha}`;
|
|
205
|
+
if (queue.items.some((item) => item.key === key)) return;
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
queue.items.push({ key, commit: { ...commit }, cwd, attribution: {
|
|
208
|
+
...attribution,
|
|
209
|
+
...(attribution.transport ? { transport: Object.freeze({ ...attribution.transport }) } : {}),
|
|
210
|
+
...(attribution.repository ? { repository: Object.freeze({ ...attribution.repository }) } : {}),
|
|
211
|
+
}, createdAt: now, attempts: 0, nextAttemptAt: now });
|
|
212
|
+
if (queue.items.length > MAX_REPORTS) {
|
|
213
|
+
debugRemote("commit provenance delivery", { capacity: queue.items.length - MAX_REPORTS });
|
|
214
|
+
queue.items.splice(0, queue.items.length - MAX_REPORTS);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function scheduleCommitReportRetry(at: number): void {
|
|
219
|
+
const queue = reportQueue();
|
|
220
|
+
if (queue.retryTimer) clearTimeout(queue.retryTimer);
|
|
221
|
+
const delay = Math.max(0, at - Date.now());
|
|
222
|
+
queue.retryTimer = setTimeout(() => {
|
|
223
|
+
queue.retryTimer = null;
|
|
224
|
+
void flushCommitReports();
|
|
225
|
+
}, delay);
|
|
226
|
+
queue.retryTimer.unref?.();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function cancelCommitReportRetryTimer(): void {
|
|
230
|
+
const queue = reportQueue();
|
|
231
|
+
if (queue.retryTimer) clearTimeout(queue.retryTimer);
|
|
232
|
+
queue.retryTimer = null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function resetCommitReportQueueForTests(): void {
|
|
236
|
+
const queue = reportQueue();
|
|
237
|
+
cancelCommitReportRetryTimer();
|
|
238
|
+
queue.items = [];
|
|
239
|
+
queue.flushing = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function flushCommitReports(now = Date.now(), deps: ReporterDeps = reporterDeps): Promise<void> {
|
|
243
|
+
const queue = reportQueue();
|
|
244
|
+
if (queue.flushing) return queue.flushing;
|
|
245
|
+
queue.flushing = (async () => {
|
|
246
|
+
const priorCount = queue.items.length;
|
|
247
|
+
queue.items = queue.items.filter((item) => now - item.createdAt <= REPORT_TTL_MS && item.attempts < 5);
|
|
248
|
+
if (priorCount !== queue.items.length) debugRemote("commit provenance delivery", { expired: priorCount - queue.items.length });
|
|
249
|
+
for (const item of [...queue.items]) {
|
|
250
|
+
if (item.nextAttemptAt > now) {
|
|
251
|
+
scheduleCommitReportRetry(item.nextAttemptAt);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
item.attempts += 1;
|
|
255
|
+
const outcome = await reportCapturedCommit(item.commit, item.cwd, item.attribution, deps);
|
|
256
|
+
if (outcome === "sent" || outcome === "permanent_rejection") {
|
|
257
|
+
debugRemote("commit provenance delivery", { sent: outcome === "sent", dropped: outcome === "permanent_rejection" });
|
|
258
|
+
queue.items = queue.items.filter((candidate) => candidate.key !== item.key);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// One reload-safe scheduled retry; lifecycle bursts cannot consume attempts.
|
|
262
|
+
debugRemote("commit provenance delivery", { retry: item.attempts });
|
|
263
|
+
const backoff = Math.min(30_000, 1000 * (2 ** Math.max(0, item.attempts - 1)));
|
|
264
|
+
item.nextAttemptAt = Date.now() + backoff + Math.floor(Math.random() * 250);
|
|
265
|
+
scheduleCommitReportRetry(item.nextAttemptAt);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
})().finally(() => { queue.flushing = null; });
|
|
269
|
+
return queue.flushing;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function gitOutput(cwd: string, args: string[]): string | null {
|
|
273
|
+
try {
|
|
274
|
+
return execFileSync("git", args, {
|
|
275
|
+
cwd,
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
278
|
+
timeout: 3000,
|
|
279
|
+
}).trim();
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Independently corroborate child journal data from immutable Git state. */
|
|
286
|
+
export function corroborateObservedCommit(observed: ObservedGitCommit, now = Date.now()): boolean {
|
|
287
|
+
const head = gitOutput(observed.worktree, ["rev-parse", "--verify", "HEAD^{commit}"])?.toLowerCase();
|
|
288
|
+
if (head !== observed.sha || observed.sha === observed.previousHead) return false;
|
|
289
|
+
const postLine = gitOutput(observed.worktree, ["rev-list", "--parents", "-n", "1", observed.sha]);
|
|
290
|
+
if (!postLine) return false;
|
|
291
|
+
const postParents = postLine.split(/\s+/u).slice(1);
|
|
292
|
+
if (observed.previousHead === null) {
|
|
293
|
+
if (observed.amend || postParents.length !== 0) return false;
|
|
294
|
+
} else if (observed.amend) {
|
|
295
|
+
const priorLine = gitOutput(observed.worktree, ["rev-list", "--parents", "-n", "1", observed.previousHead]);
|
|
296
|
+
if (!priorLine) return false;
|
|
297
|
+
const priorParents = priorLine.split(/\s+/u).slice(1);
|
|
298
|
+
if (postParents.length !== priorParents.length || postParents.some((parent, index) => parent !== priorParents[index])) return false;
|
|
299
|
+
} else if (postParents.length !== 1 || postParents[0] !== observed.previousHead) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const reflog = gitOutput(observed.worktree, ["reflog", "show", "-2", "--format=%H%x09%gs%x09%ct", "HEAD"]);
|
|
303
|
+
if (!reflog) return false;
|
|
304
|
+
const entries = reflog.split("\n").filter(Boolean).map((line) => line.split("\t"));
|
|
305
|
+
const [reflogSha = "", action = "", reflogEpoch = ""] = entries[0] ?? [];
|
|
306
|
+
const priorReflogSha = entries[1]?.[0]?.toLowerCase() ?? null;
|
|
307
|
+
const expectedAction = observed.previousHead === null ? /^commit \(initial\):/u : observed.amend ? /^commit \(amend\):/u : /^commit:/u;
|
|
308
|
+
if (reflogSha.toLowerCase() !== observed.sha || !expectedAction.test(action)) return false;
|
|
309
|
+
if (observed.previousHead === null ? entries.length !== 1 : priorReflogSha !== observed.previousHead) return false;
|
|
310
|
+
const reflogMs = Number.parseInt(reflogEpoch, 10) * 1000;
|
|
311
|
+
const committedMs = Number.parseInt(gitOutput(observed.worktree, ["show", "-s", "--format=%ct", observed.sha]) ?? "", 10) * 1000;
|
|
312
|
+
const earliest = observed.envelope.createdAt - 2000;
|
|
313
|
+
return Number.isFinite(reflogMs) && Number.isFinite(committedMs) &&
|
|
314
|
+
reflogMs >= earliest && reflogMs <= now + 2000 && committedMs >= earliest && committedMs <= now + 2000;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const stampDeps: CommitStampDeps = {
|
|
318
|
+
claims: (sessionId) => listObservedClaims(sessionId),
|
|
319
|
+
originUrl: (cwd) => Promise.resolve(gitOutput(cwd, ["remote", "get-url", "origin"])),
|
|
320
|
+
resolveProject: (cwd) => resolveNudgeProject(cwd),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Lines for the model, per tool call, from the commits that call made (df71ab6c). */
|
|
324
|
+
const STAMP_NOTES_KEY = "__devspecPiCommitStampNotes";
|
|
325
|
+
function stampNotes(): Map<string, string[]> {
|
|
326
|
+
const g = globalThis as unknown as Record<string, Map<string, string[]> | undefined>;
|
|
327
|
+
if (!(g[STAMP_NOTES_KEY] instanceof Map)) g[STAMP_NOTES_KEY] = new Map();
|
|
328
|
+
return g[STAMP_NOTES_KEY] as Map<string, string[]>;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function noteStampOutcome(observed: ObservedGitCommit): void {
|
|
332
|
+
const note = commitStampNote(observed);
|
|
333
|
+
if (!note) return;
|
|
334
|
+
const notes = stampNotes();
|
|
335
|
+
notes.set(observed.envelope.toolCallId, [...(notes.get(observed.envelope.toolCallId) ?? []), note]);
|
|
336
|
+
while (notes.size > 50) {
|
|
337
|
+
const oldest = notes.keys().next().value;
|
|
338
|
+
if (!oldest) break;
|
|
339
|
+
notes.delete(oldest);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Take the lines for one tool call, once. */
|
|
344
|
+
export function takeCommitStampNotes(toolCallId: string): string[] {
|
|
345
|
+
const notes = stampNotes();
|
|
346
|
+
const lines = notes.get(toolCallId) ?? [];
|
|
347
|
+
notes.delete(toolCallId);
|
|
348
|
+
return lines;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function ledgerAndQueue(observed: ObservedGitCommit): Promise<void> {
|
|
352
|
+
if (!corroborateObservedCommit(observed)) return;
|
|
353
|
+
noteStampOutcome(observed);
|
|
354
|
+
const message = repoCommitMessage(observed.worktree, observed.sha);
|
|
355
|
+
const commit: LedgerCommit = {
|
|
356
|
+
sha: observed.sha,
|
|
357
|
+
shortSha: observed.sha.slice(0, 7),
|
|
358
|
+
branch: observed.branch ?? "HEAD",
|
|
359
|
+
at: Date.parse(observed.observedAt) || Date.now(),
|
|
360
|
+
...(message !== null ? { commitMessage: message } : {}),
|
|
361
|
+
};
|
|
362
|
+
recordObservedCommit(commit);
|
|
363
|
+
const repository = await captureCommitRepository(observed.worktree, observed.envelope.projectId);
|
|
364
|
+
if (!repository || !observed.envelope.transport || !observed.envelope.connectionCapability) return;
|
|
365
|
+
enqueueCommitReport(commit, observed.worktree, {
|
|
366
|
+
connectionId: observed.envelope.connectionId,
|
|
367
|
+
projectId: observed.envelope.projectId,
|
|
368
|
+
observationId: observed.envelope.observationId,
|
|
369
|
+
connectionCapability: observed.envelope.connectionCapability,
|
|
370
|
+
transport: observed.envelope.transport,
|
|
371
|
+
repository,
|
|
372
|
+
});
|
|
373
|
+
debugRemote("commit ledger captured");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export default function (pi: ExtensionAPI) {
|
|
377
|
+
const pending = new Map<string, {
|
|
378
|
+
command: string;
|
|
379
|
+
cwd: string;
|
|
380
|
+
attribution: FrozenCommitAttribution | null;
|
|
381
|
+
piSessionId: string;
|
|
382
|
+
}>();
|
|
383
|
+
const injectedOriginal = new Map<string, string>();
|
|
384
|
+
|
|
385
|
+
pi.on("session_start", (event, ctx) => {
|
|
386
|
+
initializeGitObserver(ctx.sessionManager.getSessionId(), event.reason === "reload");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
390
|
+
if (event.toolName !== "bash") return;
|
|
391
|
+
const bond = getBond();
|
|
392
|
+
pending.set(event.toolCallId, {
|
|
393
|
+
command: commandOf(event.args),
|
|
394
|
+
cwd: ctx.cwd,
|
|
395
|
+
attribution: bond?.projectId ? {
|
|
396
|
+
connectionId: bond.connectionId,
|
|
397
|
+
projectId: bond.projectId,
|
|
398
|
+
...(bond.connectionCapability ? { connectionCapability: bond.connectionCapability } : {}),
|
|
399
|
+
} : null,
|
|
400
|
+
piSessionId: ctx.sessionManager.getSessionId(),
|
|
401
|
+
});
|
|
402
|
+
while (pending.size > 50) {
|
|
403
|
+
const oldest = pending.keys().next().value;
|
|
404
|
+
if (!oldest) break;
|
|
405
|
+
pending.delete(oldest);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
pi.on("tool_call", async (event) => {
|
|
410
|
+
if (event.toolName !== "bash") return;
|
|
411
|
+
const start = pending.get(event.toolCallId);
|
|
412
|
+
if (!start?.attribution?.connectionCapability || typeof event.input.command !== "string") return;
|
|
413
|
+
const transport = await captureCommitTransport();
|
|
414
|
+
const bond = getBond();
|
|
415
|
+
if (!transport || bond?.connectionId !== start.attribution.connectionId || bond?.projectId !== start.attribution.projectId ||
|
|
416
|
+
bond?.connectionCapability !== start.attribution.connectionCapability) return;
|
|
417
|
+
// Stamping is armed only while a task is claimed; a failure arms nothing.
|
|
418
|
+
const stamp = await commitStampScope({ sessionId: start.piSessionId, projectId: start.attribution.projectId, cwd: start.cwd }, stampDeps).catch(() => null);
|
|
419
|
+
const prepared = prepareGitObservation({
|
|
420
|
+
toolCallId: event.toolCallId,
|
|
421
|
+
piSessionId: start.piSessionId,
|
|
422
|
+
connectionId: start.attribution.connectionId,
|
|
423
|
+
projectId: start.attribution.projectId,
|
|
424
|
+
connectionCapability: start.attribution.connectionCapability,
|
|
425
|
+
transport,
|
|
426
|
+
stamp,
|
|
427
|
+
});
|
|
428
|
+
if (!prepared) return;
|
|
429
|
+
injectedOriginal.set(event.toolCallId, event.input.command);
|
|
430
|
+
event.input.command = `${prepared.prefix}${event.input.command}`;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
pi.on("tool_result", async (event) => {
|
|
434
|
+
if (event.toolName !== "bash") return;
|
|
435
|
+
const original = injectedOriginal.get(event.toolCallId);
|
|
436
|
+
if (original) {
|
|
437
|
+
// Keep extension/tool-result observers on the model-authored command shape.
|
|
438
|
+
event.input.command = original;
|
|
439
|
+
injectedOriginal.delete(event.toolCallId);
|
|
440
|
+
}
|
|
441
|
+
for (const observed of drainGitObservations()) await ledgerAndQueue(observed);
|
|
442
|
+
void flushCommitReports();
|
|
443
|
+
// What happened to each commit's task tag, where the model reads the output.
|
|
444
|
+
const notes = takeCommitStampNotes(event.toolCallId);
|
|
445
|
+
if (notes.length > 0) return { content: [...event.content, { type: "text" as const, text: notes.join("\n") }] };
|
|
446
|
+
return undefined;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
450
|
+
const start = pending.get(event.toolCallId);
|
|
451
|
+
pending.delete(event.toolCallId);
|
|
452
|
+
if (!start || event.isError) return;
|
|
453
|
+
// Conservative fallback for existing readable summary output.
|
|
454
|
+
const captured = captureGitCommit(start.command, outputOf(event.result), repoResolver(ctx.cwd));
|
|
455
|
+
if (!captured) return;
|
|
456
|
+
const message = repoCommitMessage(ctx.cwd, captured.sha);
|
|
457
|
+
if (message !== null) annotateCommitMessage(captured.sha, message);
|
|
458
|
+
// Legacy output parsing is ledger convenience only. It is not causal proof
|
|
459
|
+
// and must never emit plugin_observed provenance.
|
|
460
|
+
debugRemote("commit ledger captured");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
pi.on("agent_settled", async () => {
|
|
464
|
+
for (const observed of drainGitObservations()) await ledgerAndQueue(observed);
|
|
465
|
+
void flushCommitReports();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
pi.on("session_shutdown", async (event) => {
|
|
469
|
+
for (const observed of drainGitObservations()) await ledgerAndQueue(observed);
|
|
470
|
+
await Promise.race([
|
|
471
|
+
flushCommitReports(),
|
|
472
|
+
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
|
473
|
+
]);
|
|
474
|
+
if (event.reason !== "reload") cancelCommitReportRetryTimer();
|
|
475
|
+
shutdownGitObserver(event.reason);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — context injection (Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Appends the DevSpec workflow block to the system prompt on every turn via
|
|
5
|
+
* before_agent_start, but only when a DevSpec token resolves (logged-out
|
|
6
|
+
* sessions stay pristine). Local file read only — no network per turn.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { resolveToken } from "./core/auth-store.ts";
|
|
11
|
+
import { getBond } from "./core/bond-state.ts";
|
|
12
|
+
import { buildDevspecInstructions } from "./core/instructions.ts";
|
|
13
|
+
import { commitTaggingAvailable } from "./core/commit-stamp.ts";
|
|
14
|
+
import { noteInjectedContext, noteStaticContextBudget } from "./core/context-budget.ts";
|
|
15
|
+
import { REPOSITORY_CONTEXT_MESSAGE, repositorySnapshotNeeded } from "./core/repository-context.ts";
|
|
16
|
+
import { INSTRUCTION_CONTEXT_MESSAGE, instructionContextMessage } from "./core/connection-context.ts";
|
|
17
|
+
|
|
18
|
+
/** Extra block while this session is bonded as remote capacity. Exported for unit tests (byte-stability pins). */
|
|
19
|
+
export function buildBondedInstructions(): string | null {
|
|
20
|
+
const bond = getBond();
|
|
21
|
+
if (!bond) return null;
|
|
22
|
+
// Identity is named, never discovered (item 9dcbd157): the room's title +
|
|
23
|
+
// id and the owner's name ride the bonded block, refreshed only on attach
|
|
24
|
+
// transitions (and the slow title-retry), so the prompt stays byte-stable
|
|
25
|
+
// between them — the prefix-cache rule is safe.
|
|
26
|
+
const where = bond.sessionId
|
|
27
|
+
? `, attached to session ${bond.sessionTitle ? `“${bond.sessionTitle}” ` : ""}(id ${bond.sessionId})`
|
|
28
|
+
: ", sessionless";
|
|
29
|
+
const lines = [
|
|
30
|
+
`You are bonded to DevSpec as remote capacity (${bond.codename ?? bond.connectionId})${where}.`,
|
|
31
|
+
// Single addressee rule (item 5987cc24): the sender of each command is who
|
|
32
|
+
// gets addressed by name — never a bond/session "owner" title, which
|
|
33
|
+
// collides whenever a shared session's participants differ from the
|
|
34
|
+
// connection owner. Static text, so the prefix-cache rule still holds.
|
|
35
|
+
`Address the sender of each command by name (the requester named on the turn); "the owner" is a structural role, not a way to talk about a person.`,
|
|
36
|
+
"Messages prefixed [DevSpec remote — message from ...] are server-authorized remote commands delivered via the bond. Owner authority and delegated project scope differ; mutable runtime policy is served at devspec://product/remote-ingress-contract, and any delegated scope instruction is delivered with its exact command turn.",
|
|
37
|
+
"Your conversational text is delivered to the DevSpec session mechanically — never call tools to post it yourself. For non-trivial work, begin with one concise user-facing sentence confirming what you understood and what you will do next before using tools; add another progress message only at a meaningful boundary (for example, delegating, starting a long check, or becoming blocked). Do not narrate individual tools. Finish with the canonical answer.",
|
|
38
|
+
"If you need the requester before you can finish, ask with manage_directed_question and stop. The server ends Working unless you pass keep_turn because you still have work.",
|
|
39
|
+
];
|
|
40
|
+
// Mutable rule text is a persisted tail snapshot, not a rewritten system prefix.
|
|
41
|
+
// Sender response style remains on its own per-command lane.
|
|
42
|
+
lines.push("Project and owner rules arrive in DevSpec instruction-context snapshots; use the latest snapshot under the served implementation contract's precedence.");
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serializedChars(value: unknown): number {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
49
|
+
} catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function contentChars(values: unknown): number {
|
|
55
|
+
if (!Array.isArray(values)) return 0;
|
|
56
|
+
return values.reduce((sum, value) => sum + serializedChars(value), 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function (pi: ExtensionAPI) {
|
|
60
|
+
// Independent of room attachment: sessionless agents need the same project facts.
|
|
61
|
+
// Pi persists returned custom messages, so resume/reload reuses an identical snapshot.
|
|
62
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
63
|
+
const content = getBond()?.repositoryContext;
|
|
64
|
+
if (!content || !repositorySnapshotNeeded(ctx.sessionManager.getBranch(), content)) return;
|
|
65
|
+
return { message: { customType: REPOSITORY_CONTEXT_MESSAGE, content, display: false } };
|
|
66
|
+
});
|
|
67
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
68
|
+
const bond = getBond();
|
|
69
|
+
if (!bond) return;
|
|
70
|
+
const content = instructionContextMessage(bond);
|
|
71
|
+
if (!repositorySnapshotNeeded(ctx.sessionManager.getBranch(), content, INSTRUCTION_CONTEXT_MESSAGE)) return;
|
|
72
|
+
return { message: { customType: INSTRUCTION_CONTEXT_MESSAGE, content, display: false } };
|
|
73
|
+
});
|
|
74
|
+
// The room digest is a one-shot orientation message: injected once per
|
|
75
|
+
// session instance, the first turn after the pump's cold-start sync.
|
|
76
|
+
let digestInjected = false;
|
|
77
|
+
// The attach-time bond context (item 9dcbd157) is one-shot PER ATTACH —
|
|
78
|
+
// re-attaching to a different room must inject its block again, so track
|
|
79
|
+
// the injected block, not a boolean.
|
|
80
|
+
let injectedAttachContext: string | null = null;
|
|
81
|
+
|
|
82
|
+
// One-shot means once per branch, not once per process: after the conversation
|
|
83
|
+
// moves to a branch without them (the owner's Clear, item 6beb89c6, or /tree),
|
|
84
|
+
// the next turn adds them again.
|
|
85
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
86
|
+
const branch = ctx.sessionManager.getBranch();
|
|
87
|
+
const holds = (customType: string) => branch.some((entry) =>
|
|
88
|
+
entry.type === "custom_message" && (entry as { customType?: string }).customType === customType);
|
|
89
|
+
if (!holds("devspec-attach-context")) injectedAttachContext = null;
|
|
90
|
+
if (!holds("devspec-room-orientation")) digestInjected = false;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
94
|
+
const resolved = await resolveToken();
|
|
95
|
+
if (!resolved) return undefined;
|
|
96
|
+
// Stable for a bonded session; it changes only when the git stand-in or the bond does.
|
|
97
|
+
let prompt = `${event.systemPrompt}\n\n${buildDevspecInstructions({ commitTagging: commitTaggingAvailable() })}`;
|
|
98
|
+
const bonded = buildBondedInstructions();
|
|
99
|
+
if (bonded) prompt += `\n\n### DevSpec remote (bonded)\n${bonded}`;
|
|
100
|
+
|
|
101
|
+
const activeNames = new Set(pi.getActiveTools());
|
|
102
|
+
const activeTools = pi.getAllTools().filter((tool) => activeNames.has(tool.name));
|
|
103
|
+
const options = event.systemPromptOptions as { contextFiles?: unknown[]; skills?: unknown[] } | undefined;
|
|
104
|
+
noteStaticContextBudget({
|
|
105
|
+
systemPromptChars: prompt.length,
|
|
106
|
+
activeToolCount: activeTools.length,
|
|
107
|
+
activeToolDefinitionChars: serializedChars(activeTools.map((tool) => ({
|
|
108
|
+
name: tool.name,
|
|
109
|
+
description: tool.description,
|
|
110
|
+
parameters: tool.parameters,
|
|
111
|
+
promptGuidelines: tool.promptGuidelines,
|
|
112
|
+
}))),
|
|
113
|
+
contextFileCount: options?.contextFiles?.length ?? 0,
|
|
114
|
+
contextFileChars: contentChars(options?.contextFiles),
|
|
115
|
+
skillCount: options?.skills?.length ?? 0,
|
|
116
|
+
skillChars: contentChars(options?.skills),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const bond = getBond();
|
|
120
|
+
if (bond?.attachContext && bond.attachContext !== injectedAttachContext) {
|
|
121
|
+
injectedAttachContext = bond.attachContext;
|
|
122
|
+
noteInjectedContext("attach", bond.attachContext.length);
|
|
123
|
+
return {
|
|
124
|
+
systemPrompt: prompt,
|
|
125
|
+
message: {
|
|
126
|
+
customType: "devspec-attach-context",
|
|
127
|
+
content: bond.attachContext,
|
|
128
|
+
display: false,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (bond?.roomDigest && !digestInjected) {
|
|
133
|
+
digestInjected = true;
|
|
134
|
+
noteInjectedContext("roomDigest", bond.roomDigest.length);
|
|
135
|
+
return {
|
|
136
|
+
systemPrompt: prompt,
|
|
137
|
+
message: {
|
|
138
|
+
customType: "devspec-room-orientation",
|
|
139
|
+
content: bond.roomDigest,
|
|
140
|
+
display: false,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { systemPrompt: prompt };
|
|
145
|
+
});
|
|
146
|
+
}
|