@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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — owner-scoped automation-run envelopes (pure, unit-tested).
|
|
3
|
+
*
|
|
4
|
+
* `poll_connection.dispatches[]` and `get_connection_dispatch` are transport
|
|
5
|
+
* names retained by the server API. Their only executable payload in Pi is a
|
|
6
|
+
* typed automation run. Task work is selected and reserved locally; it
|
|
7
|
+
* never enters this path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AUTOMATION_RUN_PREFIX } from "./turn-lifecycle.ts";
|
|
11
|
+
|
|
12
|
+
const WAKE_KEYS = [
|
|
13
|
+
"id",
|
|
14
|
+
"kind",
|
|
15
|
+
"run_id",
|
|
16
|
+
"automation_id",
|
|
17
|
+
"automation_name",
|
|
18
|
+
"trigger_kind",
|
|
19
|
+
"owner",
|
|
20
|
+
"permission",
|
|
21
|
+
"queued_at",
|
|
22
|
+
"delivery_connection_id",
|
|
23
|
+
"requester",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
27
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
28
|
+
? value as Record<string, unknown>
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function text(value: unknown): string | undefined {
|
|
33
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isOwner(value: unknown): value is { user_id: string; display_name: string } {
|
|
37
|
+
const owner = asRecord(value);
|
|
38
|
+
return Boolean(
|
|
39
|
+
owner &&
|
|
40
|
+
Object.keys(owner).length === 2 &&
|
|
41
|
+
text(owner.user_id) &&
|
|
42
|
+
text(owner.display_name),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Only this typed envelope may enter the remote automation queue. */
|
|
47
|
+
export function isAutomationRunEnvelope(value: unknown): boolean {
|
|
48
|
+
const root = asRecord(value);
|
|
49
|
+
if (!root) return false;
|
|
50
|
+
if (Object.keys(root).length !== WAKE_KEYS.length || WAKE_KEYS.some((key) => !Object.hasOwn(root, key))) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const pressed = root.trigger_kind === "pressed";
|
|
54
|
+
const requesterOk = pressed
|
|
55
|
+
? Boolean(asRecord(root.requester) && text(asRecord(root.requester)?.user_id))
|
|
56
|
+
: root.requester === null;
|
|
57
|
+
return root.kind === "automation_run" &&
|
|
58
|
+
Boolean(text(root.id)) &&
|
|
59
|
+
root.run_id === root.id &&
|
|
60
|
+
Boolean(text(root.automation_id)) &&
|
|
61
|
+
Boolean(text(root.automation_name)) &&
|
|
62
|
+
(root.trigger_kind === "scheduled" || root.trigger_kind === "event" || root.trigger_kind === "pressed") &&
|
|
63
|
+
isOwner(root.owner) &&
|
|
64
|
+
(root.permission === "look_only" || root.permission === "can_commit" || root.permission === "can_push") &&
|
|
65
|
+
Boolean(text(root.queued_at)) &&
|
|
66
|
+
Boolean(text(root.delivery_connection_id)) &&
|
|
67
|
+
requesterOk;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Exact server-owned retry identity for a typed automation run. */
|
|
71
|
+
export function automationRunId(value: unknown): string | undefined {
|
|
72
|
+
if (!isAutomationRunEnvelope(value)) return undefined;
|
|
73
|
+
const root = asRecord(value);
|
|
74
|
+
return text(root?.run_id) ?? text(root?.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatAutomationRunMessage(value: unknown, codename: string): string {
|
|
78
|
+
const runId = automationRunId(value);
|
|
79
|
+
if (!runId) throw new Error("automation run envelope requires a run id");
|
|
80
|
+
const root = asRecord(value) ?? {};
|
|
81
|
+
const name = text(root.automation_name) ?? "automation";
|
|
82
|
+
const ownerName = isOwner(root.owner) ? root.owner.display_name : "the owner";
|
|
83
|
+
const started =
|
|
84
|
+
root.trigger_kind === "pressed"
|
|
85
|
+
? "Someone pressed Run."
|
|
86
|
+
: root.trigger_kind === "scheduled"
|
|
87
|
+
? "This run started on a schedule."
|
|
88
|
+
: "This run started because of an event.";
|
|
89
|
+
const permission =
|
|
90
|
+
root.permission === "can_push"
|
|
91
|
+
? "You MAY edit, commit and push."
|
|
92
|
+
: root.permission === "can_commit"
|
|
93
|
+
? "You MAY edit and commit locally, but MUST NOT push."
|
|
94
|
+
: "This automation is LOOK ONLY — investigate and report; do not edit, commit or push.";
|
|
95
|
+
return [
|
|
96
|
+
`${AUTOMATION_RUN_PREFIX} to ${codename}]`,
|
|
97
|
+
`DevSpec automation run ${runId} (${name}) is ready.`,
|
|
98
|
+
started,
|
|
99
|
+
`Owner: ${ownerName}`,
|
|
100
|
+
`Permission: ${permission}`,
|
|
101
|
+
"Claim it with claim_automation_run to receive the instruction, then record_automation_run truthfully.",
|
|
102
|
+
"",
|
|
103
|
+
"Automation run envelope:",
|
|
104
|
+
"```json",
|
|
105
|
+
JSON.stringify(value, null, 2),
|
|
106
|
+
"```",
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — the DevSpec mark, drawn for a terminal.
|
|
3
|
+
*
|
|
4
|
+
* The mark is a rubber duck (rubber-duck debugging). It is stored as a pixel
|
|
5
|
+
* map, one character per pixel, and rendered with half-block glyphs so a single
|
|
6
|
+
* terminal row carries TWO pixel rows: `▀` paints the top half, `▄` the bottom,
|
|
7
|
+
* `█` both. Terminal cells are roughly twice as tall as they are wide, so this
|
|
8
|
+
* makes the pixels come out square and the art keeps its proportions.
|
|
9
|
+
*
|
|
10
|
+
* To redraw the duck, edit PIXELS — it is the picture, at 1:1. Trailing blanks
|
|
11
|
+
* are optional; every row is padded to WIDTH at render time so callers can rely
|
|
12
|
+
* on each line occupying exactly WIDTH columns (the brand header sets the
|
|
13
|
+
* wordmark beside the duck and needs that guarantee).
|
|
14
|
+
*
|
|
15
|
+
* Only `▀ ▄ █` and space are used. Glyphs like `●` are East-Asian *Ambiguous*
|
|
16
|
+
* width and render double-wide on terminals configured for CJK, which shears
|
|
17
|
+
* the row it sits in — do not reintroduce one.
|
|
18
|
+
*
|
|
19
|
+
* Colours are 24-bit ANSI emitted directly: pi's theme only exposes named
|
|
20
|
+
* palette slots, and "duck yellow" is not a palette slot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Ink for each pixel character, as 24-bit ANSI "r;g;b" components. */
|
|
24
|
+
const INK: Record<string, string> = {
|
|
25
|
+
Y: "255;209;60", // body yellow
|
|
26
|
+
S: "231;172;32", // wing shade
|
|
27
|
+
O: "247;147;26", // beak orange
|
|
28
|
+
D: "40;30;20", // eye
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const BLANK = " ";
|
|
32
|
+
const RESET = "\x1b[0m";
|
|
33
|
+
|
|
34
|
+
/** The mark, one character per pixel. Two rows per rendered line. */
|
|
35
|
+
const PIXELS: readonly string[] = [
|
|
36
|
+
"",
|
|
37
|
+
" YYYYYY",
|
|
38
|
+
" YYYYYYYY",
|
|
39
|
+
" YYYYDYYYY",
|
|
40
|
+
"OOOOYYYYYYY",
|
|
41
|
+
" OOOYYYYYYY",
|
|
42
|
+
" YYYYYYYY YYY",
|
|
43
|
+
" YYYYYY YYYYYYYYYY",
|
|
44
|
+
" YYYYYYYYYYYYYYYYYY",
|
|
45
|
+
" YYYYYYYYYYYYYYYY",
|
|
46
|
+
" YYYYYYYYYYSSSYY",
|
|
47
|
+
" YYYYSSSSSSSSSSYY",
|
|
48
|
+
" YYYYYSSSSSSSSYY",
|
|
49
|
+
" YYYYSSSSSSSYY",
|
|
50
|
+
" YYYYYYYYYYY",
|
|
51
|
+
" YYYYYY",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** Columns each rendered line occupies. */
|
|
55
|
+
export const WIDTH = PIXELS.reduce((w, row) => Math.max(w, row.length), 0);
|
|
56
|
+
|
|
57
|
+
/** Rendered lines the mark occupies (two pixel rows per line). */
|
|
58
|
+
export const HEIGHT = Math.ceil(PIXELS.length / 2);
|
|
59
|
+
|
|
60
|
+
function pixelAt(row: string, column: number): string {
|
|
61
|
+
return row[column] ?? BLANK;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render one line from a pair of pixel rows.
|
|
66
|
+
*
|
|
67
|
+
* Escape codes are only emitted when the colour actually changes, and the
|
|
68
|
+
* background is cleared explicitly when leaving a two-tone cell — otherwise it
|
|
69
|
+
* would bleed across the rest of the line.
|
|
70
|
+
*/
|
|
71
|
+
function renderPair(top: string, bottom: string): string {
|
|
72
|
+
let out = "";
|
|
73
|
+
let fg = "";
|
|
74
|
+
let bg = "";
|
|
75
|
+
|
|
76
|
+
for (let column = 0; column < WIDTH; column++) {
|
|
77
|
+
const t = pixelAt(top, column);
|
|
78
|
+
const b = pixelAt(bottom, column);
|
|
79
|
+
|
|
80
|
+
let glyph = BLANK;
|
|
81
|
+
let wantFg = "";
|
|
82
|
+
let wantBg = "";
|
|
83
|
+
|
|
84
|
+
if (t !== BLANK && b !== BLANK) {
|
|
85
|
+
if (t === b) {
|
|
86
|
+
glyph = "█";
|
|
87
|
+
wantFg = INK[t] ?? "";
|
|
88
|
+
} else {
|
|
89
|
+
glyph = "▀";
|
|
90
|
+
wantFg = INK[t] ?? "";
|
|
91
|
+
wantBg = INK[b] ?? "";
|
|
92
|
+
}
|
|
93
|
+
} else if (t !== BLANK) {
|
|
94
|
+
glyph = "▀";
|
|
95
|
+
wantFg = INK[t] ?? "";
|
|
96
|
+
} else if (b !== BLANK) {
|
|
97
|
+
glyph = "▄";
|
|
98
|
+
wantFg = INK[b] ?? "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (wantFg !== fg) {
|
|
102
|
+
out += wantFg ? `\x1b[38;2;${wantFg}m` : "\x1b[39m";
|
|
103
|
+
fg = wantFg;
|
|
104
|
+
}
|
|
105
|
+
if (wantBg !== bg) {
|
|
106
|
+
out += wantBg ? `\x1b[48;2;${wantBg}m` : "\x1b[49m";
|
|
107
|
+
bg = wantBg;
|
|
108
|
+
}
|
|
109
|
+
out += glyph;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return out + RESET;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The mark as HEIGHT lines, each exactly WIDTH columns wide. */
|
|
116
|
+
export function renderDuck(): string[] {
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
for (let row = 0; row < PIXELS.length; row += 2) {
|
|
119
|
+
lines.push(renderPair(PIXELS[row] ?? "", PIXELS[row + 1] ?? ""));
|
|
120
|
+
}
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Visible width of a string, ignoring ANSI escape sequences. */
|
|
125
|
+
export function visibleWidth(text: string): number {
|
|
126
|
+
// eslint-disable-next-line no-control-regex
|
|
127
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
128
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped Pi Git observation runtime (item 2c971806).
|
|
3
|
+
*
|
|
4
|
+
* The model Bash command is not parsed. A private PATH prefix routes ordinary `git`
|
|
5
|
+
* argv through the package shim for that one tool execution. Connection/project
|
|
6
|
+
* identity stays host-side in this frozen envelope and never reaches child processes.
|
|
7
|
+
* When the host arms stamping (df71ab6c), the shim also receives the project id,
|
|
8
|
+
* the claim count, the one claimed task id and a fingerprint of the verified origin
|
|
9
|
+
* remote — never a credential — and tags plain commits by runtime/commit-stamp.mjs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
const GLOBAL_KEY = "__devspecPiGitObserver";
|
|
20
|
+
const MAX_JOURNAL_BYTES = 1024 * 1024;
|
|
21
|
+
const MAX_ENVELOPES = 100;
|
|
22
|
+
const ENVELOPE_TTL_MS = 30 * 60 * 1000;
|
|
23
|
+
const FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
24
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
25
|
+
|
|
26
|
+
export interface FrozenGitObservationEnvelope {
|
|
27
|
+
observationId: string;
|
|
28
|
+
generation: string;
|
|
29
|
+
piSessionId: string;
|
|
30
|
+
toolCallId: string;
|
|
31
|
+
connectionId: string;
|
|
32
|
+
projectId: string;
|
|
33
|
+
/** Host-only binding, never included in the shim environment or journal. */
|
|
34
|
+
connectionCapability?: string;
|
|
35
|
+
transport?: { tokenHash: string; endpoint: string };
|
|
36
|
+
createdAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the host armed the shim with for one tool call (df71ab6c). */
|
|
40
|
+
export interface CommitStampScope {
|
|
41
|
+
projectId: string;
|
|
42
|
+
/** Claimed tasks of this conversation in that project; 1 is the only count that stamps. */
|
|
43
|
+
claimCount: number;
|
|
44
|
+
/** The one claimed task, exactly when claimCount is 1. */
|
|
45
|
+
taskId: string | null;
|
|
46
|
+
/** sha256 of the verified origin URL (`git remote get-url origin`, trimmed), or null. */
|
|
47
|
+
remoteSha256: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the shim did about the task tag of one observed commit. */
|
|
51
|
+
export interface CommitStampReport {
|
|
52
|
+
outcome: "stamped" | "skipped";
|
|
53
|
+
reason: string | null;
|
|
54
|
+
task: string | null;
|
|
55
|
+
claims: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ObservedGitCommit {
|
|
59
|
+
envelope: FrozenGitObservationEnvelope;
|
|
60
|
+
sha: string;
|
|
61
|
+
branch: string | null;
|
|
62
|
+
worktree: string;
|
|
63
|
+
previousHead: string | null;
|
|
64
|
+
amend: boolean;
|
|
65
|
+
observedAt: string;
|
|
66
|
+
/** Present when stamping was armed for the call that made this commit. */
|
|
67
|
+
stamp?: CommitStampReport;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface GitObserverGeneration {
|
|
71
|
+
id: string;
|
|
72
|
+
piSessionId: string;
|
|
73
|
+
dir: string;
|
|
74
|
+
shimPath: string;
|
|
75
|
+
journalPath: string;
|
|
76
|
+
realGit: string;
|
|
77
|
+
journalOffset: number;
|
|
78
|
+
envelopes: Map<string, FrozenGitObservationEnvelope>;
|
|
79
|
+
processed: Set<string>;
|
|
80
|
+
active: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface GitObserverStore {
|
|
84
|
+
current: GitObserverGeneration | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function store(): GitObserverStore {
|
|
88
|
+
const g = globalThis as unknown as Record<string, GitObserverStore | undefined>;
|
|
89
|
+
if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = { current: null };
|
|
90
|
+
return g[GLOBAL_KEY] as GitObserverStore;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findRealGit(): string | null {
|
|
94
|
+
try {
|
|
95
|
+
const command = process.platform === "win32" ? "where" : "which";
|
|
96
|
+
const candidates = execFileSync(command, process.platform === "win32" ? ["git.exe"] : ["-a", "git"], {
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
timeout: 5000,
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
}).trim().split(/\r?\n/u);
|
|
102
|
+
// Skip our own observer shims: with a stale shim dir on PATH, plain
|
|
103
|
+
// `which git` returns the shim and delegation recurses without bound
|
|
104
|
+
// (item 8729992e). The first non-shim candidate is the real Git.
|
|
105
|
+
for (const candidate of candidates) {
|
|
106
|
+
if (candidate && !basename(dirname(candidate)).startsWith("devspec-pi-git-") && existsSync(candidate)) return candidate;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function shellQuote(value: string): string {
|
|
115
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Native Windows (c4b14794): Pi's bash tool runs Git Bash, or another bash on PATH
|
|
120
|
+
* such as MSYS2 or Cygwin. Those shells read PATH in POSIX form, so the stand-in's
|
|
121
|
+
* directory is converted with the shell's own cygpath when the call starts; a shell
|
|
122
|
+
* without it (the legacy WSL bash.exe, which runs Linux git anyway) keeps its PATH
|
|
123
|
+
* and git runs unobserved, exactly as before. Windows paths handed to node keep
|
|
124
|
+
* their drive letters, with forward slashes so sh never reads a backslash escape.
|
|
125
|
+
*/
|
|
126
|
+
function windowsPathForShell(path: string): string {
|
|
127
|
+
return path.replaceAll("\\", "/");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the bash Pi will run on Windows can reach the stand-in: Pi takes Git
|
|
132
|
+
* Bash from Program Files first, then the first bash.exe on PATH. Git Bash,
|
|
133
|
+
* MSYS2 and Cygwin ship cygpath beside their bash; the legacy WSL bash.exe does
|
|
134
|
+
* not (and runs Linux git), so there Pi stays unobserved and says so (c4b14794).
|
|
135
|
+
* A shell set by hand in Pi's settings is not consulted.
|
|
136
|
+
*/
|
|
137
|
+
export function windowsShellHasCygpath(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
138
|
+
const gitBash = [env.ProgramFiles, env["ProgramFiles(x86)"]]
|
|
139
|
+
.filter((base): base is string => Boolean(base))
|
|
140
|
+
.map((base) => join(base, "Git", "bin", "bash.exe"))
|
|
141
|
+
.find((path) => existsSync(path));
|
|
142
|
+
let shell = gitBash ?? null;
|
|
143
|
+
if (!shell) {
|
|
144
|
+
try {
|
|
145
|
+
shell = execFileSync("where", ["bash.exe"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, windowsHide: true })
|
|
146
|
+
.trim().split(/\r?\n/u).find((path) => path && existsSync(path)) ?? null;
|
|
147
|
+
} catch { shell = null; }
|
|
148
|
+
}
|
|
149
|
+
if (!shell) return false;
|
|
150
|
+
return [dirname(shell), join(dirname(dirname(shell)), "usr", "bin")].some((dir) => existsSync(join(dir, "cygpath.exe")));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pathAssignment(dir: string): string {
|
|
154
|
+
if (process.platform !== "win32") return `export PATH=${shellQuote(dir)}:"$PATH"`;
|
|
155
|
+
return `__devspec_pi_git_dir="$(/usr/bin/cygpath -u ${shellQuote(windowsPathForShell(dir))} 2>/dev/null)" && [ -n "$__devspec_pi_git_dir" ] && export PATH="$__devspec_pi_git_dir:$PATH"; unset __devspec_pi_git_dir`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cleanupGeneration(generation: GitObserverGeneration | null): void {
|
|
159
|
+
if (!generation) return;
|
|
160
|
+
generation.active = false;
|
|
161
|
+
generation.envelopes.clear();
|
|
162
|
+
try { rmSync(generation.dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function initializeGitObserver(piSessionId: string, preserveReload = false): boolean {
|
|
166
|
+
const state = store();
|
|
167
|
+
if (process.platform === "win32" && !windowsShellHasCygpath()) {
|
|
168
|
+
state.current = null;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (preserveReload && state.current?.active && state.current.piSessionId === piSessionId) return true;
|
|
172
|
+
cleanupGeneration(state.current);
|
|
173
|
+
const realGit = findRealGit();
|
|
174
|
+
if (!realGit) {
|
|
175
|
+
state.current = null;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const dir = mkdtempSync(join(tmpdir(), "devspec-pi-git-"));
|
|
180
|
+
chmodSync(dir, 0o700);
|
|
181
|
+
const shimPath = join(dir, "git");
|
|
182
|
+
const modulePath = join(dir, "git-observer-shim.mjs");
|
|
183
|
+
const source = fileURLToPath(new URL("../../runtime/git-observer-shim.mjs", import.meta.url));
|
|
184
|
+
copyFileSync(source, modulePath);
|
|
185
|
+
chmodSync(modulePath, 0o600);
|
|
186
|
+
// The shim imports its stamping rules from beside itself (df71ab6c).
|
|
187
|
+
const stampModulePath = join(dir, "commit-stamp.mjs");
|
|
188
|
+
copyFileSync(fileURLToPath(new URL("../../runtime/commit-stamp.mjs", import.meta.url)), stampModulePath);
|
|
189
|
+
chmodSync(stampModulePath, 0o600);
|
|
190
|
+
// Use the exact host runtime, never /usr/bin/env node or the mutated PATH.
|
|
191
|
+
// On Windows the same sh script is what Git Bash runs for `git`.
|
|
192
|
+
const runtime = process.platform === "win32" ? windowsPathForShell(process.execPath) : process.execPath;
|
|
193
|
+
const shimModule = process.platform === "win32" ? windowsPathForShell(modulePath) : modulePath;
|
|
194
|
+
writeFileSync(shimPath, `#!/bin/sh\nexec ${shellQuote(runtime)} ${shellQuote(shimModule)} "$@"\n`, { mode: 0o700 });
|
|
195
|
+
chmodSync(shimPath, 0o700);
|
|
196
|
+
const journalPath = join(dir, "observations.jsonl");
|
|
197
|
+
writeFileSync(journalPath, "", { mode: 0o600 });
|
|
198
|
+
state.current = {
|
|
199
|
+
id: randomUUID(),
|
|
200
|
+
piSessionId,
|
|
201
|
+
dir,
|
|
202
|
+
shimPath,
|
|
203
|
+
journalPath,
|
|
204
|
+
realGit,
|
|
205
|
+
journalOffset: 0,
|
|
206
|
+
envelopes: new Map(),
|
|
207
|
+
processed: new Set(),
|
|
208
|
+
active: true,
|
|
209
|
+
};
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
cleanupGeneration(state.current);
|
|
213
|
+
state.current = null;
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Whether this Pi process has a working git stand-in. */
|
|
219
|
+
export function gitObserverActive(): boolean {
|
|
220
|
+
return store().current?.active === true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function shutdownGitObserver(reason: "quit" | "reload" | "new" | "resume" | "fork"): void {
|
|
224
|
+
if (reason === "reload") return;
|
|
225
|
+
const state = store();
|
|
226
|
+
cleanupGeneration(state.current);
|
|
227
|
+
state.current = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Return a generic shell prefix; the original command is never inspected. */
|
|
231
|
+
export function prepareGitObservation(params: {
|
|
232
|
+
toolCallId: string;
|
|
233
|
+
piSessionId: string;
|
|
234
|
+
connectionId: string;
|
|
235
|
+
projectId: string;
|
|
236
|
+
connectionCapability?: string;
|
|
237
|
+
transport?: { tokenHash: string; endpoint: string };
|
|
238
|
+
stamp?: CommitStampScope | null;
|
|
239
|
+
}): { observationId: string; prefix: string } | null {
|
|
240
|
+
const generation = store().current;
|
|
241
|
+
if (!generation?.active || generation.piSessionId !== params.piSessionId) return null;
|
|
242
|
+
if (!UUID.test(params.connectionId) || !UUID.test(params.projectId)) return null;
|
|
243
|
+
|
|
244
|
+
const observationId = randomUUID();
|
|
245
|
+
generation.envelopes.set(observationId, Object.freeze({
|
|
246
|
+
observationId,
|
|
247
|
+
generation: generation.id,
|
|
248
|
+
piSessionId: params.piSessionId,
|
|
249
|
+
toolCallId: params.toolCallId,
|
|
250
|
+
connectionId: params.connectionId,
|
|
251
|
+
projectId: params.projectId,
|
|
252
|
+
...(params.connectionCapability ? { connectionCapability: params.connectionCapability } : {}),
|
|
253
|
+
...(params.transport ? { transport: Object.freeze({ ...params.transport }) } : {}),
|
|
254
|
+
createdAt: Date.now(),
|
|
255
|
+
}));
|
|
256
|
+
while (generation.envelopes.size > MAX_ENVELOPES) {
|
|
257
|
+
const oldest = generation.envelopes.keys().next().value;
|
|
258
|
+
if (!oldest) break;
|
|
259
|
+
generation.envelopes.delete(oldest);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const assignments = [
|
|
263
|
+
pathAssignment(generation.dir),
|
|
264
|
+
`export DEVSPEC_PI_REAL_GIT=${shellQuote(generation.realGit)}`,
|
|
265
|
+
`export DEVSPEC_PI_GIT_OBSERVER_DIR=${shellQuote(generation.dir)}`,
|
|
266
|
+
`export DEVSPEC_PI_GIT_OBSERVER_JOURNAL=${shellQuote(generation.journalPath)}`,
|
|
267
|
+
`export DEVSPEC_PI_GIT_OBSERVER_GENERATION=${shellQuote(generation.id)}`,
|
|
268
|
+
`export DEVSPEC_PI_GIT_OBSERVATION_ID=${shellQuote(observationId)}`,
|
|
269
|
+
`export DEVSPEC_PI_GIT_OBSERVER_SESSION=${shellQuote(params.piSessionId)}`,
|
|
270
|
+
// Always set or cleared, so a value exported by an enclosing session can
|
|
271
|
+
// never arm stamping for this one.
|
|
272
|
+
...stampAssignments(params.stamp ?? null),
|
|
273
|
+
];
|
|
274
|
+
return { observationId, prefix: `${assignments.join("; ")}\n` };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const STAMP_ENV_NAMES = ["DEVSPEC_PI_STAMP_PROJECT", "DEVSPEC_PI_STAMP_CLAIMS", "DEVSPEC_PI_STAMP_TASK", "DEVSPEC_PI_STAMP_REMOTE_SHA256"] as const;
|
|
278
|
+
const SHA256 = /^[0-9a-f]{64}$/u;
|
|
279
|
+
|
|
280
|
+
function stampAssignments(stamp: CommitStampScope | null): string[] {
|
|
281
|
+
const valid = stamp && UUID.test(stamp.projectId) && Number.isSafeInteger(stamp.claimCount) && stamp.claimCount >= 1 &&
|
|
282
|
+
(stamp.claimCount === 1 ? Boolean(stamp.taskId && UUID.test(stamp.taskId)) : stamp.taskId === null) &&
|
|
283
|
+
(stamp.remoteSha256 === null || SHA256.test(stamp.remoteSha256));
|
|
284
|
+
if (!stamp || !valid) return [`unset ${STAMP_ENV_NAMES.join(" ")}`];
|
|
285
|
+
return [
|
|
286
|
+
`export DEVSPEC_PI_STAMP_PROJECT=${shellQuote(stamp.projectId.toLowerCase())}`,
|
|
287
|
+
`export DEVSPEC_PI_STAMP_CLAIMS=${shellQuote(String(stamp.claimCount))}`,
|
|
288
|
+
stamp.taskId ? `export DEVSPEC_PI_STAMP_TASK=${shellQuote(stamp.taskId.toLowerCase())}` : "unset DEVSPEC_PI_STAMP_TASK",
|
|
289
|
+
stamp.remoteSha256 ? `export DEVSPEC_PI_STAMP_REMOTE_SHA256=${shellQuote(stamp.remoteSha256)}` : "unset DEVSPEC_PI_STAMP_REMOTE_SHA256",
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function stampReport(value: unknown): CommitStampReport | undefined {
|
|
294
|
+
if (!value || typeof value !== "object") return undefined;
|
|
295
|
+
const row = value as Record<string, unknown>;
|
|
296
|
+
if (row.v !== 1 || (row.outcome !== "stamped" && row.outcome !== "skipped")) return undefined;
|
|
297
|
+
if (row.reason !== null && (typeof row.reason !== "string" || !/^[a-z_]{1,40}$/u.test(row.reason))) return undefined;
|
|
298
|
+
if (row.task !== null && (typeof row.task !== "string" || !UUID.test(row.task))) return undefined;
|
|
299
|
+
if (!Number.isSafeInteger(row.claims) || (row.claims as number) < 1) return undefined;
|
|
300
|
+
if (row.outcome === "stamped" && (row.task === null || row.reason !== null)) return undefined;
|
|
301
|
+
return { outcome: row.outcome, reason: row.reason as string | null, task: row.task as string | null, claims: row.claims as number };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function validJournalRow(value: unknown): value is {
|
|
305
|
+
v: 1;
|
|
306
|
+
generation: string;
|
|
307
|
+
observationId: string;
|
|
308
|
+
sha: string;
|
|
309
|
+
branch: string | null;
|
|
310
|
+
worktree: string;
|
|
311
|
+
previousHead: string | null;
|
|
312
|
+
amend: boolean;
|
|
313
|
+
observedAt: string;
|
|
314
|
+
} {
|
|
315
|
+
if (!value || typeof value !== "object") return false;
|
|
316
|
+
const row = value as Record<string, unknown>;
|
|
317
|
+
return row.v === 1 && typeof row.generation === "string" && UUID.test(String(row.observationId ?? "")) &&
|
|
318
|
+
typeof row.sha === "string" && FULL_SHA.test(row.sha) &&
|
|
319
|
+
(row.branch === null || (typeof row.branch === "string" && row.branch.length <= 255)) &&
|
|
320
|
+
typeof row.worktree === "string" && row.worktree.length > 0 && row.worktree.length <= 4096 &&
|
|
321
|
+
(row.previousHead === null || (typeof row.previousHead === "string" && FULL_SHA.test(row.previousHead))) &&
|
|
322
|
+
typeof row.amend === "boolean" && typeof row.observedAt === "string";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function drainGitObservations(now = Date.now()): ObservedGitCommit[] {
|
|
326
|
+
const generation = store().current;
|
|
327
|
+
if (!generation?.active) return [];
|
|
328
|
+
for (const [id, envelope] of generation.envelopes) {
|
|
329
|
+
if (now - envelope.createdAt > ENVELOPE_TTL_MS) generation.envelopes.delete(id);
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const data = readFileSync(generation.journalPath);
|
|
333
|
+
if (generation.journalOffset > data.byteLength) generation.journalOffset = 0;
|
|
334
|
+
if (data.byteLength > MAX_JOURNAL_BYTES) {
|
|
335
|
+
// Drop the oversized generation slice at a tool boundary and recover for
|
|
336
|
+
// future observations instead of remaining permanently disabled.
|
|
337
|
+
writeFileSync(generation.journalPath, "", { mode: 0o600 });
|
|
338
|
+
generation.journalOffset = 0;
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
const tail = data.subarray(generation.journalOffset).toString("utf8");
|
|
342
|
+
const lastNewline = tail.lastIndexOf("\n");
|
|
343
|
+
if (lastNewline < 0) return [];
|
|
344
|
+
generation.journalOffset += Buffer.byteLength(tail.slice(0, lastNewline + 1));
|
|
345
|
+
const observed: ObservedGitCommit[] = [];
|
|
346
|
+
for (const line of tail.slice(0, lastNewline).split("\n")) {
|
|
347
|
+
if (!line || line.length > 4096) continue;
|
|
348
|
+
let parsed: unknown;
|
|
349
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
350
|
+
if (!validJournalRow(parsed) || parsed.generation !== generation.id) continue;
|
|
351
|
+
const processedKey = `${parsed.observationId}:${parsed.sha}:${parsed.worktree}`;
|
|
352
|
+
if (generation.processed.has(processedKey)) continue;
|
|
353
|
+
const envelope = generation.envelopes.get(parsed.observationId);
|
|
354
|
+
if (!envelope || envelope.generation !== generation.id || envelope.piSessionId !== generation.piSessionId) continue;
|
|
355
|
+
generation.processed.add(processedKey);
|
|
356
|
+
while (generation.processed.size > 500) {
|
|
357
|
+
const oldest = generation.processed.values().next().value;
|
|
358
|
+
if (!oldest) break;
|
|
359
|
+
generation.processed.delete(oldest);
|
|
360
|
+
}
|
|
361
|
+
observed.push({
|
|
362
|
+
envelope,
|
|
363
|
+
sha: parsed.sha,
|
|
364
|
+
branch: parsed.branch,
|
|
365
|
+
worktree: parsed.worktree,
|
|
366
|
+
previousHead: parsed.previousHead,
|
|
367
|
+
amend: parsed.amend,
|
|
368
|
+
observedAt: parsed.observedAt,
|
|
369
|
+
...(() => { const stamp = stampReport((parsed as { stamp?: unknown }).stamp); return stamp ? { stamp } : {}; })(),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return observed;
|
|
373
|
+
} catch {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function resetGitObserverForTests(): void {
|
|
379
|
+
const state = store();
|
|
380
|
+
cleanupGeneration(state.current);
|
|
381
|
+
state.current = null;
|
|
382
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub identity link at connect (ADR "Protecting intent" §14; server side
|
|
3
|
+
* c45f75d4; Pi side 306fb64a). For a project whose repositories are on GitHub,
|
|
4
|
+
* `register_connection` returns `github_identity`: `linked` with the person's
|
|
5
|
+
* GitHub login(s), or `link_required` with one link to open. Linking is one
|
|
6
|
+
* click through DevSpec's own GitHub App, identity only: DevSpec keeps no token.
|
|
7
|
+
*
|
|
8
|
+
* DevSpec builds the link and decides when to offer it. Pi only shows it to the
|
|
9
|
+
* person at the terminal, because the person, not the model, opens it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** What Pi shows the person when the link is needed, or null when nothing is. */
|
|
13
|
+
export function githubIdentityNotice(value: unknown): string | null {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
15
|
+
const prompt = value as { status?: unknown; url?: unknown };
|
|
16
|
+
if (prompt.status !== "link_required" || typeof prompt.url !== "string") return null;
|
|
17
|
+
let url: URL;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(prompt.url);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
// Only a web link is offered to be opened; anything else is not shown.
|
|
24
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1"))) return null;
|
|
25
|
+
return `DevSpec: this project's repositories are on GitHub. Link your GitHub account so your pull requests and commits show as yours. GitHub confirms who you are, and DevSpec keeps no access to your account: ${url.toString()}`;
|
|
26
|
+
}
|