@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,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DevSpec-Pi private Git observer shim.
|
|
4
|
+
*
|
|
5
|
+
* This process is injected into PATH for one model-originated Bash call. It is
|
|
6
|
+
* not installed as a Git hook and never changes repository or machine config.
|
|
7
|
+
* It delegates Git byte-for-byte, then emits only a bounded local observation
|
|
8
|
+
* when a successful direct `commit` can be proved from Trace2 + the actual repo.
|
|
9
|
+
*
|
|
10
|
+
* The one exception (DevSpec item df71ab6c, owner-approved): when the host armed
|
|
11
|
+
* stamping for this call, a plain `git commit` in the claimed task's project gets
|
|
12
|
+
* the task's `[devspec:<uuid>]` appended to its subject, by the rules in
|
|
13
|
+
* commit-stamp.mjs. Every other invocation, and every commit those rules do not
|
|
14
|
+
* cover, is delegated exactly as before.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
19
|
+
import { planCommitStamp, readStampScope, repositoryInProject, unplannedReport } from "./commit-stamp.mjs";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
import { createServer } from "node:net";
|
|
23
|
+
|
|
24
|
+
const FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
25
|
+
const MAX_TRACE_BYTES = 1024 * 1024;
|
|
26
|
+
const TRACE_CLOSE_GRACE_MS = 250;
|
|
27
|
+
|
|
28
|
+
function env(name) {
|
|
29
|
+
const value = process.env[name];
|
|
30
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(realGit, args, options = {}) {
|
|
34
|
+
return spawnSync(realGit, args, {
|
|
35
|
+
cwd: options.cwd,
|
|
36
|
+
env: options.env ?? process.env,
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: options.stdio ?? ["ignore", "pipe", "ignore"],
|
|
39
|
+
windowsHide: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function delegate(realGit, args, childEnv, stdin = null) {
|
|
44
|
+
const child = spawn(realGit, args, {
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
env: childEnv,
|
|
47
|
+
stdio: stdin ? ["pipe", "inherit", "inherit"] : "inherit",
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
if (stdin) {
|
|
51
|
+
// The message git would have read from stdin: tagged, or the exact bytes.
|
|
52
|
+
child.stdin.on("error", () => { /* git exited without reading */ });
|
|
53
|
+
child.stdin.write(stdin.bytes);
|
|
54
|
+
if (stdin.rest) process.stdin.pipe(child.stdin);
|
|
55
|
+
else child.stdin.end();
|
|
56
|
+
}
|
|
57
|
+
const forwarded = [];
|
|
58
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]) {
|
|
59
|
+
const handler = () => {
|
|
60
|
+
try { child.kill(signal); } catch { /* best-effort forwarding */ }
|
|
61
|
+
};
|
|
62
|
+
process.on(signal, handler);
|
|
63
|
+
forwarded.push([signal, handler]);
|
|
64
|
+
}
|
|
65
|
+
const result = await new Promise((resolve) => {
|
|
66
|
+
child.once("error", (error) => resolve({ code: 126, signal: null, error }));
|
|
67
|
+
child.once("exit", (code, signal) => resolve({ code: code ?? 1, signal, error: null }));
|
|
68
|
+
});
|
|
69
|
+
for (const [signal, handler] of forwarded) process.off(signal, handler);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Git for Windows has no Unix domain sockets, so there Trace2 writes one file per
|
|
75
|
+
* git process into a private directory instead (c4b14794). The events are the same.
|
|
76
|
+
*/
|
|
77
|
+
function startDirectoryTraceCapture(observerDir) {
|
|
78
|
+
const dir = join(observerDir, `trace-${randomUUID()}`);
|
|
79
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
80
|
+
return {
|
|
81
|
+
target: dir,
|
|
82
|
+
async finish() {
|
|
83
|
+
try {
|
|
84
|
+
let bytes = 0;
|
|
85
|
+
const parts = [];
|
|
86
|
+
for (const name of readdirSync(dir).sort()) {
|
|
87
|
+
const part = readFileSync(join(dir, name), "utf8");
|
|
88
|
+
bytes += Buffer.byteLength(part);
|
|
89
|
+
if (bytes > MAX_TRACE_BYTES) return null;
|
|
90
|
+
parts.push(part.endsWith("\n") ? part : `${part}\n`);
|
|
91
|
+
}
|
|
92
|
+
return parts.join("");
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
} finally {
|
|
96
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function startTraceCapture(observerDir) {
|
|
103
|
+
if (process.platform === "win32") return startDirectoryTraceCapture(observerDir);
|
|
104
|
+
const socketPath = join(observerDir, `trace-${randomUUID()}.sock`);
|
|
105
|
+
// Git connects only to a socket path that fits sun_path with its NUL; a longer
|
|
106
|
+
// one (a deep temp directory) can bind in node yet receive nothing from git.
|
|
107
|
+
if (Buffer.byteLength(socketPath) > 100) return startDirectoryTraceCapture(observerDir);
|
|
108
|
+
const chunks = [];
|
|
109
|
+
const sockets = new Set();
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
let overflow = false;
|
|
112
|
+
let resolveIdle = null;
|
|
113
|
+
const server = createServer((socket) => {
|
|
114
|
+
sockets.add(socket);
|
|
115
|
+
socket.on("data", (chunk) => {
|
|
116
|
+
bytes += chunk.length;
|
|
117
|
+
if (bytes <= MAX_TRACE_BYTES) chunks.push(chunk);
|
|
118
|
+
else overflow = true;
|
|
119
|
+
});
|
|
120
|
+
socket.on("close", () => {
|
|
121
|
+
sockets.delete(socket);
|
|
122
|
+
if (sockets.size === 0 && resolveIdle) {
|
|
123
|
+
resolveIdle();
|
|
124
|
+
resolveIdle = null;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
try {
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
server.once("error", reject);
|
|
131
|
+
server.listen(socketPath, () => {
|
|
132
|
+
server.off("error", reject);
|
|
133
|
+
resolve();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// A socket path past the platform limit (a deep temp directory, or one
|
|
138
|
+
// with spaces) cannot be bound: the same events go to files instead.
|
|
139
|
+
try { server.close(); } catch { /* never listening */ }
|
|
140
|
+
return startDirectoryTraceCapture(observerDir);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
target: `af_unix:stream:${socketPath}`,
|
|
144
|
+
async finish() {
|
|
145
|
+
if (sockets.size > 0) {
|
|
146
|
+
await Promise.race([
|
|
147
|
+
new Promise((resolve) => { resolveIdle = resolve; }),
|
|
148
|
+
new Promise((resolve) => setTimeout(resolve, TRACE_CLOSE_GRACE_MS)),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
for (const socket of sockets) socket.destroy();
|
|
152
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
153
|
+
try { rmSync(socketPath, { force: true }); } catch { /* best effort */ }
|
|
154
|
+
return overflow ? null : Buffer.concat(chunks).toString("utf8");
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readTrace(raw) {
|
|
160
|
+
if (!raw) return [];
|
|
161
|
+
const rows = [];
|
|
162
|
+
for (const line of raw.split("\n")) {
|
|
163
|
+
if (!line) continue;
|
|
164
|
+
try {
|
|
165
|
+
const row = JSON.parse(line);
|
|
166
|
+
if (row && typeof row === "object") rows.push(row);
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return rows;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseGitGlobalArgs(args) {
|
|
175
|
+
const globals = [];
|
|
176
|
+
const takesValue = new Set(["-C", "-c", "--config-env", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path"]);
|
|
177
|
+
const flags = new Set(["--no-pager", "-P", "--paginate", "-p", "--bare", "--no-replace-objects", "--literal-pathspecs", "--no-literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", "--no-optional-locks"]);
|
|
178
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
179
|
+
const arg = args[i];
|
|
180
|
+
if (takesValue.has(arg)) {
|
|
181
|
+
const value = args[i + 1];
|
|
182
|
+
if (typeof value !== "string") return null;
|
|
183
|
+
globals.push(arg, value);
|
|
184
|
+
i += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (arg.startsWith("-C") && arg.length > 2) {
|
|
188
|
+
globals.push(arg);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (["--config-env=", "--git-dir=", "--work-tree=", "--namespace=", "--super-prefix=", "--exec-path="].some((prefix) => arg.startsWith(prefix))) {
|
|
192
|
+
globals.push(arg);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (flags.has(arg)) {
|
|
196
|
+
globals.push(arg);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (arg.startsWith("-")) return null;
|
|
200
|
+
return { globals, command: arg, index: i };
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function preflightRepository(realGit, args) {
|
|
206
|
+
const parsed = parseGitGlobalArgs(args);
|
|
207
|
+
if (!parsed) return null;
|
|
208
|
+
const root = run(realGit, [...parsed.globals, "rev-parse", "--show-toplevel"]);
|
|
209
|
+
if (root.status !== 0 || !root.stdout.trim()) return null;
|
|
210
|
+
let worktree;
|
|
211
|
+
try { worktree = realpathSync(root.stdout.trim()); } catch { return null; }
|
|
212
|
+
const before = run(realGit, [...parsed.globals, "rev-parse", "--verify", "HEAD^{commit}"]);
|
|
213
|
+
const previousHead = before.status === 0 && FULL_SHA.test(before.stdout.trim().toLowerCase())
|
|
214
|
+
? before.stdout.trim().toLowerCase()
|
|
215
|
+
: null;
|
|
216
|
+
return { worktree, previousHead, globals: parsed.globals, command: parsed.command, commandIndex: parsed.index };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function directCommit(rows) {
|
|
220
|
+
// The outermost process: first on a socket stream, and the shortest SID when
|
|
221
|
+
// Trace2 wrote one file per process (Windows), whatever order they are read in.
|
|
222
|
+
const rootSid = rows
|
|
223
|
+
.filter((row) => row.event === "start" && typeof row.sid === "string")
|
|
224
|
+
.reduce((best, row) => (!best || row.sid.split("/").length < best.split("/").length ? row.sid : best), null);
|
|
225
|
+
if (!rootSid) return null;
|
|
226
|
+
const commit = rows.find((row) => {
|
|
227
|
+
if (row.event !== "cmd_name" || row.name !== "commit" || typeof row.sid !== "string") return false;
|
|
228
|
+
if (row.sid === rootSid && row.hierarchy === "commit") return true;
|
|
229
|
+
// A Git alias expands in a child SID. Do not accept hook-launched commit/commit
|
|
230
|
+
// hierarchies or any other descendant process as the causal command.
|
|
231
|
+
return row.sid.startsWith(`${rootSid}/`) && typeof row.hierarchy === "string" &&
|
|
232
|
+
row.hierarchy.includes("_run_git_alias_") && row.hierarchy.endsWith("/commit");
|
|
233
|
+
});
|
|
234
|
+
if (!commit) return null;
|
|
235
|
+
const start = rows.find((row) => row.event === "start" && row.sid === commit.sid);
|
|
236
|
+
const argv = Array.isArray(start?.argv) ? start.argv : [];
|
|
237
|
+
if (argv.includes("--dry-run")) return null;
|
|
238
|
+
const repo = rows.find((row) => row.event === "def_repo" && row.sid === commit.sid && (row.repo === 1 || row.repo == null));
|
|
239
|
+
if (!repo || typeof repo.worktree !== "string" || !repo.worktree) return null;
|
|
240
|
+
return { worktree: repo.worktree, amend: argv.includes("--amend") };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function inspectCommit(realGit, worktree, previousHead, amend, startedAt, endedAt) {
|
|
244
|
+
const head = run(realGit, ["-C", worktree, "rev-parse", "--verify", "HEAD^{commit}"]);
|
|
245
|
+
const sha = head.status === 0 ? head.stdout.trim().toLowerCase() : "";
|
|
246
|
+
if (!FULL_SHA.test(sha) || sha === previousHead) return null;
|
|
247
|
+
|
|
248
|
+
const postLine = run(realGit, ["-C", worktree, "rev-list", "--parents", "-n", "1", sha]);
|
|
249
|
+
if (postLine.status !== 0) return null;
|
|
250
|
+
const postParents = postLine.stdout.trim().split(/\s+/u).slice(1);
|
|
251
|
+
if (previousHead === null) {
|
|
252
|
+
if (amend || postParents.length !== 0) return null;
|
|
253
|
+
} else if (amend) {
|
|
254
|
+
const priorLine = run(realGit, ["-C", worktree, "rev-list", "--parents", "-n", "1", previousHead]);
|
|
255
|
+
if (priorLine.status !== 0) return null;
|
|
256
|
+
const priorParents = priorLine.stdout.trim().split(/\s+/u).slice(1);
|
|
257
|
+
if (postParents.length !== priorParents.length || postParents.some((parent, index) => parent !== priorParents[index])) return null;
|
|
258
|
+
} else if (postParents.length !== 1 || postParents[0] !== previousHead) {
|
|
259
|
+
// Includes merge completion, concurrent/head-moving hooks, and unrelated refs.
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A direct commit must be the current HEAD and the newest HEAD reflog entry.
|
|
264
|
+
// This deliberately fails open when a hook moves HEAD after committing.
|
|
265
|
+
const reflog = run(realGit, ["-C", worktree, "reflog", "show", "-2", "--format=%H%x09%gs%x09%ct", "HEAD"]);
|
|
266
|
+
if (reflog.status !== 0) return null;
|
|
267
|
+
const entries = reflog.stdout.trim().split("\n").filter(Boolean).map((line) => line.split("\t"));
|
|
268
|
+
const [reflogSha = "", action = "", reflogEpoch = ""] = entries[0] ?? [];
|
|
269
|
+
const priorReflogSha = entries[1]?.[0]?.toLowerCase() ?? null;
|
|
270
|
+
const expectedAction = previousHead === null ? /^commit \(initial\):/u : amend ? /^commit \(amend\):/u : /^commit:/u;
|
|
271
|
+
if (reflogSha.toLowerCase() !== sha || !expectedAction.test(action)) return null;
|
|
272
|
+
if (previousHead === null ? entries.length !== 1 : priorReflogSha !== previousHead) return null;
|
|
273
|
+
|
|
274
|
+
const epochMs = Number.parseInt(reflogEpoch, 10) * 1000;
|
|
275
|
+
if (!Number.isFinite(epochMs) || epochMs < startedAt - 2000 || epochMs > endedAt + 2000) return null;
|
|
276
|
+
|
|
277
|
+
const committedAt = run(realGit, ["-C", worktree, "show", "-s", "--format=%ct", sha]);
|
|
278
|
+
const committedAtMs = Number.parseInt(committedAt.stdout.trim(), 10) * 1000;
|
|
279
|
+
if (committedAt.status !== 0 || !Number.isFinite(committedAtMs) || committedAtMs < startedAt - 2000 || committedAtMs > endedAt + 2000) return null;
|
|
280
|
+
|
|
281
|
+
const branchResult = run(realGit, ["-C", worktree, "symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
282
|
+
const branch = branchResult.status === 0 && branchResult.stdout.trim() ? branchResult.stdout.trim().slice(0, 255) : null;
|
|
283
|
+
return { sha, branch, previousHead, amend };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const realGit = env("DEVSPEC_PI_REAL_GIT");
|
|
287
|
+
const observerDir = env("DEVSPEC_PI_GIT_OBSERVER_DIR");
|
|
288
|
+
const journal = env("DEVSPEC_PI_GIT_OBSERVER_JOURNAL");
|
|
289
|
+
const generation = env("DEVSPEC_PI_GIT_OBSERVER_GENERATION");
|
|
290
|
+
const observationId = env("DEVSPEC_PI_GIT_OBSERVATION_ID");
|
|
291
|
+
const expectedSession = env("DEVSPEC_PI_GIT_OBSERVER_SESSION");
|
|
292
|
+
const actualSession = env("PI_SESSION_ID");
|
|
293
|
+
const args = process.argv.slice(2);
|
|
294
|
+
|
|
295
|
+
if (!realGit) process.exit(126);
|
|
296
|
+
|
|
297
|
+
// Never delegate into another observer shim. If a stale generation's dir
|
|
298
|
+
// reaches PATH, findRealGit can hand us a shim as "the real git"; every hop
|
|
299
|
+
// then re-runs preflight through the next shim, which inherits this env and
|
|
300
|
+
// self-spawns without bound (measured: >1,100 processes, 12G swap, machine
|
|
301
|
+
// lockup — item 8729992e). Refusing the call fails one git command instead.
|
|
302
|
+
try {
|
|
303
|
+
if (basename(dirname(realpathSync(realGit))).startsWith("devspec-pi-git-")) process.exit(126);
|
|
304
|
+
} catch {
|
|
305
|
+
process.exit(126);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const eligible = Boolean(
|
|
309
|
+
observerDir && journal && generation && observationId && expectedSession && actualSession === expectedSession &&
|
|
310
|
+
!process.env.GIT_TRACE2_EVENT && !process.env.GIT_TRACE2,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const preflight = eligible ? preflightRepository(realGit, args) : null;
|
|
314
|
+
const stampScope = eligible ? readStampScope(process.env) : null;
|
|
315
|
+
const gitRun = (gitArgs) => run(realGit, gitArgs);
|
|
316
|
+
let stampPlan = null;
|
|
317
|
+
if (preflight && stampScope && preflight.command === "commit") {
|
|
318
|
+
try {
|
|
319
|
+
mkdirSync(observerDir, { recursive: true, mode: 0o700 });
|
|
320
|
+
stampPlan = planCommitStamp({
|
|
321
|
+
args, commandIndex: preflight.commandIndex, globals: preflight.globals, cwd: process.cwd(),
|
|
322
|
+
worktree: preflight.worktree, scope: stampScope, run: gitRun, privateDir: observerDir,
|
|
323
|
+
});
|
|
324
|
+
} catch {
|
|
325
|
+
stampPlan = null; // stamping never stops git; the commit runs untouched
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let traceCapture = null;
|
|
329
|
+
let childEnv = process.env;
|
|
330
|
+
if (preflight) {
|
|
331
|
+
try {
|
|
332
|
+
mkdirSync(observerDir, { recursive: true, mode: 0o700 });
|
|
333
|
+
traceCapture = await startTraceCapture(observerDir);
|
|
334
|
+
childEnv = { ...process.env, GIT_TRACE2_EVENT: traceCapture.target };
|
|
335
|
+
} catch {
|
|
336
|
+
traceCapture = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const startedAt = Date.now();
|
|
341
|
+
const result = await delegate(realGit, stampPlan?.args ?? args, childEnv,
|
|
342
|
+
stampPlan?.stdin ? { bytes: stampPlan.stdin, rest: stampPlan.stdinRest === true } : null);
|
|
343
|
+
const endedAt = Date.now();
|
|
344
|
+
if (stampPlan?.privateFile) {
|
|
345
|
+
try { rmSync(stampPlan.privateFile, { force: true }); } catch { /* best effort */ }
|
|
346
|
+
}
|
|
347
|
+
const trace = traceCapture ? await traceCapture.finish().catch(() => null) : null;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
if (trace && result.code === 0 && !result.signal) {
|
|
351
|
+
const found = directCommit(readTrace(trace));
|
|
352
|
+
let actualWorktree = null;
|
|
353
|
+
try { actualWorktree = found ? realpathSync(found.worktree) : null; } catch { actualWorktree = null; }
|
|
354
|
+
const commit = found && actualWorktree === preflight?.worktree
|
|
355
|
+
? inspectCommit(realGit, actualWorktree, preflight.previousHead, found.amend, startedAt, endedAt)
|
|
356
|
+
: null;
|
|
357
|
+
if (found && actualWorktree && commit) {
|
|
358
|
+
let stamp;
|
|
359
|
+
if (stampScope) {
|
|
360
|
+
try {
|
|
361
|
+
stamp = stampPlan?.report ?? unplannedReport(stampScope, repositoryInProject(gitRun, actualWorktree, null, stampScope));
|
|
362
|
+
} catch { stamp = undefined; }
|
|
363
|
+
}
|
|
364
|
+
const line = JSON.stringify({
|
|
365
|
+
v: 1,
|
|
366
|
+
generation,
|
|
367
|
+
observationId,
|
|
368
|
+
sha: commit.sha,
|
|
369
|
+
branch: commit.branch,
|
|
370
|
+
worktree: actualWorktree,
|
|
371
|
+
previousHead: commit.previousHead,
|
|
372
|
+
amend: commit.amend,
|
|
373
|
+
observedAt: new Date(endedAt).toISOString(),
|
|
374
|
+
...(stamp ? { stamp } : {}),
|
|
375
|
+
});
|
|
376
|
+
if (Buffer.byteLength(line) <= 4096) appendFileSync(journal, `${line}\n`, { encoding: "utf8", mode: 0o600 });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
// Observation never affects Git.
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (result.error) {
|
|
384
|
+
console.error(result.error.message);
|
|
385
|
+
process.exit(126);
|
|
386
|
+
}
|
|
387
|
+
if (result.signal) {
|
|
388
|
+
try { process.kill(process.pid, result.signal); } catch { process.exit(1); }
|
|
389
|
+
}
|
|
390
|
+
process.exit(result.code);
|
package/skills/.gitkeep
ADDED
|
File without changes
|
package/themes/.gitkeep
ADDED
|
File without changes
|