@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,88 @@
|
|
|
1
|
+
import { parseIngressAttachment, type IngressAttachment } from './remote-payload.ts';
|
|
2
|
+
import { MAX_ATTACHMENTS, MAX_TOTAL_ATTACHMENT_BYTES } from './attachment-limits.ts';
|
|
3
|
+
|
|
4
|
+
export interface CommandOffer {
|
|
5
|
+
version: 1;
|
|
6
|
+
connection_id: string;
|
|
7
|
+
source_session_id: string;
|
|
8
|
+
attachment_id: string;
|
|
9
|
+
source_message_id: string;
|
|
10
|
+
blocking_attempt_id: string;
|
|
11
|
+
attachments: IngressAttachment[];
|
|
12
|
+
}
|
|
13
|
+
export interface CommandOfferScope {
|
|
14
|
+
nativeSessionId: string;
|
|
15
|
+
connectionId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
attachmentId: string;
|
|
18
|
+
}
|
|
19
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const keys = ['version', 'connection_id', 'source_session_id', 'attachment_id', 'source_message_id', 'blocking_attempt_id', 'attachments'];
|
|
21
|
+
/** Offers contain no executable text or provenance and never enter model context. */
|
|
22
|
+
export function parseCommandOfferPoll(value: unknown, scope: CommandOfferScope): { supported: boolean; stale: boolean; offer: CommandOffer | null } {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid command offer response');
|
|
24
|
+
const root = value as Record<string, unknown>;
|
|
25
|
+
if (root.command_offer_version === undefined) return { supported: false, stale: false, offer: null };
|
|
26
|
+
if (root.command_offer_version !== 1 || typeof root.command_handoff_stale !== 'boolean') throw new Error('Unsupported command offer response');
|
|
27
|
+
if (root.command_offer === null) return { supported: true, stale: root.command_handoff_stale, offer: null };
|
|
28
|
+
const raw = root.command_offer;
|
|
29
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid command offer');
|
|
30
|
+
const row = raw as Record<string, unknown>;
|
|
31
|
+
if (Object.keys(row).length !== keys.length || Object.keys(row).some(key => !keys.includes(key)) || row.version !== 1) throw new Error('Invalid command offer fields');
|
|
32
|
+
for (const key of keys.slice(1, -1)) if (typeof row[key] !== 'string' || !uuid.test(row[key] as string)) throw new Error('Invalid command offer identity');
|
|
33
|
+
if (row.connection_id !== scope.connectionId || row.source_session_id !== scope.sessionId || row.attachment_id !== scope.attachmentId) throw new Error('Command offer attachment changed');
|
|
34
|
+
if (!Array.isArray(row.attachments) || row.attachments.length > MAX_ATTACHMENTS) throw new Error('Invalid command offer attachments');
|
|
35
|
+
const attachments = row.attachments.map((item, index) => parseIngressAttachment(item, `command offer.attachments[${index}]`));
|
|
36
|
+
if (attachments.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0) > MAX_TOTAL_ATTACHMENT_BYTES) throw new Error('Command offer attachment budget exceeded');
|
|
37
|
+
return { supported: true, stale: root.command_handoff_stale, offer: { version: 1,
|
|
38
|
+
connection_id: row.connection_id as string, source_session_id: row.source_session_id as string,
|
|
39
|
+
attachment_id: row.attachment_id as string, source_message_id: row.source_message_id as string,
|
|
40
|
+
blocking_attempt_id: row.blocking_attempt_id as string, attachments } };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function commandOfferAction(input: {
|
|
44
|
+
current: boolean; exactInteractionApplied: boolean; foregroundIdle: boolean;
|
|
45
|
+
hasPendingMessages: boolean; preemptEnabled: boolean; abortAlreadyRequested: boolean;
|
|
46
|
+
}): 'ignore' | 'wait' | 'abort' | 'ready' {
|
|
47
|
+
if (!input.current || !input.exactInteractionApplied) return 'ignore';
|
|
48
|
+
if (input.foregroundIdle) return input.hasPendingMessages ? 'wait' : 'ready';
|
|
49
|
+
return input.preemptEnabled && !input.abortAlreadyRequested ? 'abort' : 'wait';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const COMMAND_PREEMPT_ENTRY = 'devspec-command-preemption';
|
|
53
|
+
/** Negative cancellation budget only; journal/history can NEVER authorize work. */
|
|
54
|
+
export class CommandPreemptionBudget {
|
|
55
|
+
private sources = new Set<string>();
|
|
56
|
+
private scope: string | null = null;
|
|
57
|
+
bind(scope: CommandOfferScope | null, entries: readonly unknown[]): void {
|
|
58
|
+
const key = scope ? JSON.stringify(scope) : null;
|
|
59
|
+
if (key === this.scope) return;
|
|
60
|
+
this.scope = key; this.sources.clear();
|
|
61
|
+
if (!scope) return;
|
|
62
|
+
for (const raw of entries) {
|
|
63
|
+
const entry = raw as { type?: unknown; customType?: unknown; data?: Record<string, unknown> };
|
|
64
|
+
const data = entry?.data;
|
|
65
|
+
if (entry?.type !== 'custom' || entry.customType !== COMMAND_PREEMPT_ENTRY || !data || data.version !== 1 ||
|
|
66
|
+
data.nativeSessionId !== scope.nativeSessionId || data.connectionId !== scope.connectionId ||
|
|
67
|
+
data.sessionId !== scope.sessionId || data.attachmentId !== scope.attachmentId ||
|
|
68
|
+
typeof data.sourceMessageId !== 'string' || !uuid.test(data.sourceMessageId)) continue;
|
|
69
|
+
if (data.phase === 'canonical_received') { this.sources.delete(data.sourceMessageId); continue; }
|
|
70
|
+
if (data.phase !== undefined && data.phase !== 'abort_requested') continue;
|
|
71
|
+
if (this.sources.size < 1000) this.sources.add(data.sourceMessageId);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
has(source: string): boolean { return this.sources.has(source); }
|
|
75
|
+
request(source: string, scope: CommandOfferScope, append: (type: string, data: unknown) => void): boolean {
|
|
76
|
+
if (this.scope !== JSON.stringify(scope) || !uuid.test(source) || this.has(source) || this.sources.size >= 1000) return false;
|
|
77
|
+
append(COMMAND_PREEMPT_ENTRY, { version: 1, phase: 'abort_requested', ...scope, sourceMessageId: source });
|
|
78
|
+
this.sources.add(source);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/** Retires only the cancellation budget, not an application/execution ACK. */
|
|
82
|
+
received(source: string, scope: CommandOfferScope, append: (type: string, data: unknown) => void): boolean {
|
|
83
|
+
if (this.scope !== JSON.stringify(scope) || !this.sources.has(source)) return false;
|
|
84
|
+
append(COMMAND_PREEMPT_ENTRY, { version: 1, phase: 'canonical_received', ...scope, sourceMessageId: source });
|
|
85
|
+
this.sources.delete(source);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process access to Pi's command context (items e82a690a, 6beb89c6).
|
|
3
|
+
*
|
|
4
|
+
* ctx.reload() and ctx.navigateTree() exist only on ExtensionCommandContext.
|
|
5
|
+
* The pump/event ctx is ExtensionContext and does not have them. sendUserMessage("/devspec.reload")
|
|
6
|
+
* is not a substitute: it calls prompt({ expandPromptTemplates: false }) and
|
|
7
|
+
* skips _tryExecuteExtensionCommand, so the slash becomes a user prompt.
|
|
8
|
+
*
|
|
9
|
+
* ExtensionRunner.createCommandContext() is the official factory the TUI
|
|
10
|
+
* uses. We remember the live runner via the public bindCommandContext hook
|
|
11
|
+
* (called on session_start and after every reload) and then call
|
|
12
|
+
* createCommandContext().reload() — the same handleReloadCommand path — or
|
|
13
|
+
* createCommandContext().navigateTree(), the same path as /tree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ExtensionRunner } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const GLOBAL_KEY = "__devspecPiExtensionRunner";
|
|
19
|
+
|
|
20
|
+
/** The part of Pi's command context the owner's controls use. */
|
|
21
|
+
export type CommandContextLike = {
|
|
22
|
+
reload?: () => Promise<void>;
|
|
23
|
+
navigateTree?: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
|
|
24
|
+
waitForIdle?: () => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type RunnerLike = {
|
|
28
|
+
createCommandContext?: () => CommandContextLike;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function store(): { current: RunnerLike | null } {
|
|
32
|
+
const g = globalThis as unknown as Record<string, { current: RunnerLike | null } | undefined>;
|
|
33
|
+
if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = { current: null };
|
|
34
|
+
return g[GLOBAL_KEY] as { current: RunnerLike | null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let patched = false;
|
|
38
|
+
|
|
39
|
+
/** Install once per process. Safe to call from every extension file. */
|
|
40
|
+
export function rememberExtensionRunner(): void {
|
|
41
|
+
if (patched) return;
|
|
42
|
+
const proto = ExtensionRunner.prototype as unknown as {
|
|
43
|
+
bindCommandContext?: (actions?: unknown) => void;
|
|
44
|
+
};
|
|
45
|
+
const original = proto.bindCommandContext;
|
|
46
|
+
if (typeof original !== "function") return;
|
|
47
|
+
patched = true;
|
|
48
|
+
proto.bindCommandContext = function (this: RunnerLike, actions?: unknown) {
|
|
49
|
+
store().current = this;
|
|
50
|
+
return original.call(this, actions);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extensionCommandContext(): CommandContextLike | null {
|
|
55
|
+
const runner = store().current;
|
|
56
|
+
if (!runner || typeof runner.createCommandContext !== "function") return null;
|
|
57
|
+
try {
|
|
58
|
+
return runner.createCommandContext();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical binding between a poll-delivered owner command and the exact Pi
|
|
3
|
+
* user message that starts its model turn.
|
|
4
|
+
*
|
|
5
|
+
* Pi's sendUserMessage API does not accept opaque metadata, so the adapter
|
|
6
|
+
* retains immutable server identity out-of-band until Pi emits the exact user
|
|
7
|
+
* message back through message_start. Matching the complete text (not a preview
|
|
8
|
+
* or content hash) keeps repeated owner messages as separate segments.
|
|
9
|
+
*
|
|
10
|
+
* Lifetime (item 6f4136d8): a binding is valid from remember() until its
|
|
11
|
+
* message_start take(). Anything still pending after its command closes is a
|
|
12
|
+
* stray that a later terminal input with identical text must never inherit.
|
|
13
|
+
* Callers drop those via forgetByCommand() on completion and clear() on every
|
|
14
|
+
* authority rotation (stop, revive/replacement, attach change).
|
|
15
|
+
*/
|
|
16
|
+
export interface CanonicalQueueItem {
|
|
17
|
+
hiddenContent: string;
|
|
18
|
+
details: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CommandTurnBinding {
|
|
22
|
+
turnId: string;
|
|
23
|
+
commandMessageId: string;
|
|
24
|
+
canonicalQueueItem?: CanonicalQueueItem;
|
|
25
|
+
/**
|
|
26
|
+
* Every command this delivery carries, oldest first, with its own delivery
|
|
27
|
+
* provenance_ref and message id (item 7a4d04fd): the host opens each one's
|
|
28
|
+
* request when the message starts, so every write the turn makes, and every
|
|
29
|
+
* wake its work causes, names the sender it serves.
|
|
30
|
+
*/
|
|
31
|
+
requests?: CommandRequestRef[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CommandRequestRef {
|
|
35
|
+
provenanceRef: string;
|
|
36
|
+
messageId: string;
|
|
37
|
+
/** The connection's owner sent it (owner authority), so it is the owner's own request. */
|
|
38
|
+
fromOwner?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class CommandTurnBindings {
|
|
42
|
+
private pending: Array<{ message: string; binding: CommandTurnBinding }> = [];
|
|
43
|
+
|
|
44
|
+
remember(
|
|
45
|
+
message: string,
|
|
46
|
+
turnId: string | undefined,
|
|
47
|
+
commandMessageId: string | undefined,
|
|
48
|
+
canonicalQueueItem?: CanonicalQueueItem,
|
|
49
|
+
requests?: readonly CommandRequestRef[],
|
|
50
|
+
): void {
|
|
51
|
+
if (!turnId || !commandMessageId) return;
|
|
52
|
+
this.pending.push({ message, binding: { turnId, commandMessageId, ...(canonicalQueueItem ? { canonicalQueueItem } : {}), ...(requests?.length ? { requests: requests.map((ref) => ({ ...ref })) } : {}) } });
|
|
53
|
+
if (this.pending.length > 128) this.pending.shift();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
peek(message: string): CommandTurnBinding | undefined {
|
|
57
|
+
return this.pending.find((entry) => entry.message === message)?.binding;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
take(message: string): CommandTurnBinding | undefined {
|
|
61
|
+
const index = this.pending.findIndex((entry) => entry.message === message);
|
|
62
|
+
if (index === -1) return undefined;
|
|
63
|
+
const [entry] = this.pending.splice(index, 1);
|
|
64
|
+
return entry?.binding;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
forget(message: string, turnId: string | undefined, commandMessageId: string | undefined): void {
|
|
68
|
+
if (!turnId || !commandMessageId) return;
|
|
69
|
+
const index = this.pending.findIndex((entry) =>
|
|
70
|
+
entry.message === message &&
|
|
71
|
+
entry.binding.turnId === turnId &&
|
|
72
|
+
entry.binding.commandMessageId === commandMessageId
|
|
73
|
+
);
|
|
74
|
+
if (index !== -1) this.pending.splice(index, 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
forgetByCommand(turnId: string | undefined, commandMessageId: string | undefined): void {
|
|
78
|
+
if (!turnId) return;
|
|
79
|
+
this.pending = this.pending.filter((entry) =>
|
|
80
|
+
entry.binding.turnId !== turnId ||
|
|
81
|
+
(commandMessageId !== undefined && entry.binding.commandMessageId !== commandMessageId)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
clear(): void {
|
|
86
|
+
this.pending = [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — the mechanical commit ledger (item efe7e0a7).
|
|
3
|
+
*
|
|
4
|
+
* An agent that commits through the bash tool should never TRANSCRIBE a commit
|
|
5
|
+
* SHA — it should HAVE it. Ali watched a hand-typed SHA land wrong past the
|
|
6
|
+
* short prefix (2026-08-12): "it should be mechanical, impossible to get
|
|
7
|
+
* wrong." So: the extension recognizes ordinary Git commit/merge result output,
|
|
8
|
+
* keeps the resulting SHAs, and can corroborate an explicitly supplied full object.
|
|
9
|
+
* Missing/short hints stay missing/short: only the server's scoped evidence can
|
|
10
|
+
* select an implementation commit or establish project-wide prefix uniqueness.
|
|
11
|
+
*
|
|
12
|
+
* Same principle class as mechanical-only delivery (cc9e8c43): when the system
|
|
13
|
+
* knows the SHA, it says it — never the model's job to remember or transcribe.
|
|
14
|
+
* This is accuracy assistance, not provenance-source proof. Like all cross-extension
|
|
15
|
+
* state, the ledger lives on globalThis
|
|
16
|
+
* (jiti gives every extension file a fresh module graph).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
export interface LedgerCommit {
|
|
22
|
+
/** Full SHA when the repo resolved it; the short SHA otherwise. */
|
|
23
|
+
sha: string;
|
|
24
|
+
shortSha: string;
|
|
25
|
+
branch: string;
|
|
26
|
+
at: number;
|
|
27
|
+
/** Actual commit-object message observed after a recognized Pi Bash commit. */
|
|
28
|
+
commitMessage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const GLOBAL_KEY = "__devspecPiCommitLedger";
|
|
32
|
+
const MAX_ENTRIES = 20;
|
|
33
|
+
/** Retain recent explicit-object metadata for 30 minutes; never infer work identity. */
|
|
34
|
+
const RECENT_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
interface LedgerStore {
|
|
37
|
+
commits: LedgerCommit[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function store(): LedgerStore {
|
|
41
|
+
const g = globalThis as unknown as Record<string, LedgerStore | undefined>;
|
|
42
|
+
if (!g[GLOBAL_KEY]) g[GLOBAL_KEY] = { commits: [] };
|
|
43
|
+
return g[GLOBAL_KEY] as LedgerStore;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function recentCommits(): LedgerCommit[] {
|
|
47
|
+
return store().commits.map((commit) => ({ ...commit }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function recordObservedCommit(entry: LedgerCommit): void {
|
|
51
|
+
const s = store();
|
|
52
|
+
const existing = s.commits.findIndex((c) => c.sha === entry.sha);
|
|
53
|
+
if (existing >= 0) s.commits.splice(existing, 1);
|
|
54
|
+
s.commits.push(entry);
|
|
55
|
+
if (s.commits.length > MAX_ENTRIES) s.commits.splice(0, s.commits.length - MAX_ENTRIES);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A resolver answers the full SHA for a rev, or null when it cannot. */
|
|
59
|
+
export type GitResolver = (rev: string) => string | null;
|
|
60
|
+
|
|
61
|
+
/** The real resolver: ask the repo at `cwd`. Worktrees share the object
|
|
62
|
+
* store, so a commit made in a linked worktree resolves from the main one. */
|
|
63
|
+
const GIT_REVISION = /^(?:HEAD|[0-9a-f]{7,40}(?:\^\{commit\})?)$/;
|
|
64
|
+
const COMMIT_SHA_INPUT = /^[0-9a-f]{7,40}$/;
|
|
65
|
+
|
|
66
|
+
export function repoResolver(cwd: string): GitResolver {
|
|
67
|
+
return (rev: string) => {
|
|
68
|
+
// rev-parse accepts revision syntax powerful enough to reach reflogs and,
|
|
69
|
+
// when interpolated into a shell, arbitrary commands. This resolver needs
|
|
70
|
+
// only HEAD or a hexadecimal commit candidate with an optional peel.
|
|
71
|
+
if (!GIT_REVISION.test(rev)) return null;
|
|
72
|
+
try {
|
|
73
|
+
const out = execFileSync("git", ["rev-parse", "--verify", rev], {
|
|
74
|
+
cwd,
|
|
75
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
76
|
+
})
|
|
77
|
+
.toString()
|
|
78
|
+
.trim();
|
|
79
|
+
return /^[0-9a-f]{40}$/.test(out) ? out : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read the immutable commit object's message without a shell. */
|
|
87
|
+
export function repoCommitMessage(cwd: string, sha: string): string | null {
|
|
88
|
+
if (!/^[0-9a-f]{40}$/u.test(sha)) return null;
|
|
89
|
+
try {
|
|
90
|
+
return execFileSync("git", ["show", "-s", "--format=%B", sha], {
|
|
91
|
+
cwd,
|
|
92
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
93
|
+
}).toString().replace(/\n+$/u, "");
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Attach the immutable commit message to one already captured ledger entry. */
|
|
100
|
+
export function annotateCommitMessage(sha: string, message: string): LedgerCommit | null {
|
|
101
|
+
const entry = store().commits.find((commit) => commit.sha === sha);
|
|
102
|
+
if (!entry) return null;
|
|
103
|
+
entry.commitMessage = message;
|
|
104
|
+
return { ...entry };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const COMMIT_LINE = /^\[([^\s\]]+)\s+([0-9a-f]{7,10})\]/m;
|
|
108
|
+
const GIT_WRITE = /\bgit\s+(commit|merge)\b/;
|
|
109
|
+
const NO_NEW_COMMIT = /Already up to date|nothing to commit|nothing added to commit/i;
|
|
110
|
+
|
|
111
|
+
/** Parse the commit summary line git prints: `[branch shortsha] subject`. */
|
|
112
|
+
export function parseCommitOutput(output: string): { branch: string; shortSha: string } | null {
|
|
113
|
+
const m = COMMIT_LINE.exec(output);
|
|
114
|
+
if (!m) return null;
|
|
115
|
+
return { branch: m[1], shortSha: m[2] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The watcher entry point — call from tool_execution_end for successful bash
|
|
120
|
+
* calls. Returns the captured commit, or null when the command left no new
|
|
121
|
+
* commit (non-git commands, failed writes, "Already up to date" merges).
|
|
122
|
+
*/
|
|
123
|
+
export function captureGitCommit(command: string, output: string, resolve: GitResolver): LedgerCommit | null {
|
|
124
|
+
if (!GIT_WRITE.test(command)) return null;
|
|
125
|
+
if (NO_NEW_COMMIT.test(output)) return null;
|
|
126
|
+
|
|
127
|
+
const parsed = parseCommitOutput(output);
|
|
128
|
+
if (parsed) {
|
|
129
|
+
const full = resolve(parsed.shortSha) ?? parsed.shortSha;
|
|
130
|
+
const entry: LedgerCommit = { sha: full, shortSha: parsed.shortSha, branch: parsed.branch, at: Date.now() };
|
|
131
|
+
recordObservedCommit(entry);
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Merge output does not identify the resulting object. Without a first-class
|
|
136
|
+
// Git hook or a before/after observation, resolving HEAD could capture an old
|
|
137
|
+
// commit after arbitrary Bash text. Fail open: require an explicit SHA later.
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type CommitShaVerdict = {
|
|
142
|
+
ok: true;
|
|
143
|
+
/** The sha to send, or null to send none (the server's own fill runs). */
|
|
144
|
+
sha: string | null;
|
|
145
|
+
/** Retained result shape; caller hints are never locally auto-filled. */
|
|
146
|
+
autoFilled: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* True for every short hint or an uncorroborated full object. Local prefix
|
|
149
|
+
* matches cannot establish uniqueness across the project. Hints pass to the
|
|
150
|
+
* server unchanged for scoped resolution/provisional storage (ADR 60efaed0).
|
|
151
|
+
* A local miss is never an identity verdict in a multi-repo project.
|
|
152
|
+
*/
|
|
153
|
+
unverified?: boolean;
|
|
154
|
+
commitMessage?: string;
|
|
155
|
+
} | { ok: false; error: string };
|
|
156
|
+
|
|
157
|
+
function ledgerVerdict(commit: LedgerCommit, input: string): CommitShaVerdict {
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
sha: input,
|
|
161
|
+
autoFilled: false,
|
|
162
|
+
...(commit.commitMessage ? { commitMessage: commit.commitMessage } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate commit_sha shape before record_implementation. A full object can
|
|
168
|
+
* carry locally corroborated message bytes, never authorship or round proof.
|
|
169
|
+
* A sole recent commit is NOT an item/claim binding (P7 / e47fcd4e: report-only
|
|
170
|
+
* P6 inherited the preceding P4 commit). A cwd or ledger prefix match is NOT
|
|
171
|
+
* project-wide uniqueness. Preserve omissions and every supplied hint's bytes
|
|
172
|
+
* for the server's scoped resolver. Only malformed normalized shapes refuse;
|
|
173
|
+
* local misses, ambiguous prefixes and absent hints always remain fail-open.
|
|
174
|
+
*/
|
|
175
|
+
export function validateCommitSha(input: string | undefined, resolve: GitResolver, now: number = Date.now()): CommitShaVerdict {
|
|
176
|
+
if (input == null || (typeof input === "string" && !input.trim())) {
|
|
177
|
+
return { ok: true, sha: null, autoFilled: false, unverified: true };
|
|
178
|
+
}
|
|
179
|
+
const normalized = typeof input === "string" ? input.trim().toLowerCase() : "";
|
|
180
|
+
if (!COMMIT_SHA_INPUT.test(normalized)) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
error: "commit_sha must contain 7–40 hexadecimal characters; revision syntax and shell text are not accepted.",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (normalized.length < 40) return { ok: true, sha: input, autoFilled: false, unverified: true };
|
|
187
|
+
|
|
188
|
+
const recent = recentCommits().filter((c) => now - c.at <= RECENT_MS);
|
|
189
|
+
const hit = recent.find((c) => c.sha === normalized);
|
|
190
|
+
if (hit) return ledgerVerdict(hit, input);
|
|
191
|
+
|
|
192
|
+
const resolved = resolve(`${normalized}^{commit}`);
|
|
193
|
+
if (resolved === normalized) return { ok: true, sha: input, autoFilled: false };
|
|
194
|
+
return { ok: true, sha: input, autoFilled: false, unverified: true };
|
|
195
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of commit tag stamping (DevSpec item df71ab6c).
|
|
3
|
+
*
|
|
4
|
+
* Pi's git stand-in tags a plain commit with the claimed task's
|
|
5
|
+
* `[devspec:<uuid>]` (runtime/commit-stamp.mjs). This module decides what the
|
|
6
|
+
* stand-in is armed with for one Bash call, and words what the model is told
|
|
7
|
+
* about each commit it made. How tags link commits to work is the served
|
|
8
|
+
* contract's commit_provenance_contract; nothing here blocks a commit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { getBond } from "./bond-state.ts";
|
|
13
|
+
import { gitObserverActive, type CommitStampReport, type CommitStampScope } from "./git-observer.ts";
|
|
14
|
+
import type { ObservedClaim } from "./work-state.ts";
|
|
15
|
+
|
|
16
|
+
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;
|
|
17
|
+
|
|
18
|
+
export interface CommitStampDeps {
|
|
19
|
+
/** This conversation's observed claims. */
|
|
20
|
+
claims(sessionId: string): ObservedClaim[];
|
|
21
|
+
/** The raw `git remote get-url origin` of a directory, or null. */
|
|
22
|
+
originUrl(cwd: string): Promise<string | null>;
|
|
23
|
+
/** The project a directory positively belongs to (pin or server-verified remote), or null. */
|
|
24
|
+
resolveProject(cwd: string): Promise<string | null>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether Pi can tag this conversation's commits at all: a working git stand-in
|
|
29
|
+
* (POSIX today, so not native Windows) and a bonded connection it arms per call.
|
|
30
|
+
* Without it, the model is told to write the tag itself (c4b14794).
|
|
31
|
+
*/
|
|
32
|
+
export function commitTaggingAvailable(): boolean {
|
|
33
|
+
const bond = getBond();
|
|
34
|
+
return gitObserverActive() && Boolean(bond?.projectId && bond.connectionCapability);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function originFingerprint(url: string): string {
|
|
38
|
+
return createHash("sha256").update(url.trim()).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What to arm the stand-in with, or null when no task is claimed in this project.
|
|
43
|
+
* The task id goes only with exactly one claim. The origin fingerprint goes only
|
|
44
|
+
* when the call's own directory is verified to belong to the project; the stand-in
|
|
45
|
+
* also accepts a pin naming the project, and leaves every other repository alone.
|
|
46
|
+
*/
|
|
47
|
+
export async function commitStampScope(input: { sessionId: string; projectId: string; cwd: string }, deps: CommitStampDeps): Promise<CommitStampScope | null> {
|
|
48
|
+
if (!UUID.test(input.projectId)) return null;
|
|
49
|
+
const projectId = input.projectId.toLowerCase();
|
|
50
|
+
const claims = deps.claims(input.sessionId)
|
|
51
|
+
.filter((claim) => UUID.test(claim.taskId) && (!claim.projectId || claim.projectId.toLowerCase() === projectId));
|
|
52
|
+
const taskIds = [...new Set(claims.map((claim) => claim.taskId.toLowerCase()))];
|
|
53
|
+
if (taskIds.length === 0) return null;
|
|
54
|
+
let remoteSha256: string | null = null;
|
|
55
|
+
try {
|
|
56
|
+
const url = await deps.originUrl(input.cwd);
|
|
57
|
+
if (url?.trim() && (await deps.resolveProject(input.cwd))?.toLowerCase() === projectId) remoteSha256 = originFingerprint(url);
|
|
58
|
+
} catch { remoteSha256 = null; }
|
|
59
|
+
return { projectId, claimCount: taskIds.length, taskId: taskIds.length === 1 ? taskIds[0]! : null, remoteSha256 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const REASON_WORDS: Readonly<Record<string, string>> = {
|
|
63
|
+
amend: "it amends an earlier commit",
|
|
64
|
+
fixup: "it is a fixup commit",
|
|
65
|
+
squash: "it is a squash commit",
|
|
66
|
+
reuse_message: "it reuses another commit's message",
|
|
67
|
+
no_edit: "it keeps an earlier commit's message",
|
|
68
|
+
edit: "its message is edited in an editor",
|
|
69
|
+
editor: "its message was written in an editor",
|
|
70
|
+
template: "its message starts from a template",
|
|
71
|
+
interactive: "it was made interactively",
|
|
72
|
+
unrecognised_option: "it uses a git commit option Pi does not read",
|
|
73
|
+
conflicting_messages: "it names its message more than once",
|
|
74
|
+
not_plain_commit: "it was not run as a plain git commit",
|
|
75
|
+
message_file_unreadable: "Pi could not read its message file",
|
|
76
|
+
message_unreadable: "Pi could not read its message",
|
|
77
|
+
terminal_stdin: "its message was typed at a terminal",
|
|
78
|
+
no_subject: "its message has no line the tag could go on",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The line the model reads under the command's output, or null. A commit outside
|
|
83
|
+
* the project, or one that already names a task, needs nothing said.
|
|
84
|
+
*/
|
|
85
|
+
export function commitStampNote(commit: { sha: string; stamp?: CommitStampReport }): string | null {
|
|
86
|
+
const stamp = commit.stamp;
|
|
87
|
+
if (!stamp) return null;
|
|
88
|
+
const short = commit.sha.slice(0, 7);
|
|
89
|
+
if (stamp.outcome === "stamped" && stamp.task) {
|
|
90
|
+
return `DevSpec: Pi added [devspec:${stamp.task}] to the message of commit ${short}, for the one task claimed in this conversation.`;
|
|
91
|
+
}
|
|
92
|
+
if (stamp.reason === "outside_project" || stamp.reason === "has_reference") return null;
|
|
93
|
+
if (stamp.reason === "several_claims") {
|
|
94
|
+
return `DevSpec: Pi did not add a task tag to commit ${short}: ${stamp.claims} tasks are claimed in this conversation, so it cannot tell which one this commit is for. Write the right task's [devspec:<full-uuid>] into the message yourself.`;
|
|
95
|
+
}
|
|
96
|
+
const why = (stamp.reason && REASON_WORDS[stamp.reason]) ?? "its message could not be changed safely";
|
|
97
|
+
return `DevSpec: Pi did not add [devspec:${stamp.task}] to commit ${short}, because ${why}. If this commit is for that task, write the tag into its message yourself.`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — shared configuration.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint, environment variables, and on-disk locations. Everything here is
|
|
5
|
+
* pure and dependency-free so it loads in any mode (tui / rpc / json / print)
|
|
6
|
+
* and under `node --test` (erasable TypeScript only).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* DevSpec's hostnames are split by role: the machine API (MCP) lives on the
|
|
14
|
+
* `api.` host, human-facing pages (settings, handoff) on the `app.` host, and
|
|
15
|
+
* the marketing site stays on the bare `devspec.ai` apex. Staging mirrors the
|
|
16
|
+
* same split on `devspecstaging.com`. Never derive one host from another.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Prod MCP endpoint (stateless Streamable HTTP, POST-only, bearer auth). */
|
|
20
|
+
export const DEFAULT_MCP_URL = "https://api.devspec.ai/api/mcp";
|
|
21
|
+
|
|
22
|
+
/** Staging MCP endpoint — active development happens here (dev Clerk/Supabase). */
|
|
23
|
+
export const STAGING_MCP_URL = "https://api.devspecstaging.com/api/mcp";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The Agent setup page (`/settings/agents`): where users create, reveal and
|
|
27
|
+
* revoke their account-wide access token in the DevSpec web app (app host, not
|
|
28
|
+
* the API). Customer copy links to it by URL rather than naming a menu path.
|
|
29
|
+
*/
|
|
30
|
+
export const AGENTS_SETTINGS_URL = "https://app.devspec.ai/settings/agents";
|
|
31
|
+
export const STAGING_AGENTS_SETTINGS_URL = "https://app.devspecstaging.com/settings/agents";
|
|
32
|
+
|
|
33
|
+
/** Env var that always wins over the on-disk token store (CI / cloud workers). */
|
|
34
|
+
export const TOKEN_ENV_VAR = "DEVSPEC_MCP_TOKEN";
|
|
35
|
+
|
|
36
|
+
/** Full endpoint override (any environment). Wins over DEVSPEC_ENV. */
|
|
37
|
+
export const URL_ENV_VAR = "DEVSPEC_MCP_URL";
|
|
38
|
+
|
|
39
|
+
/** Environment shorthand: "staging" selects the staging endpoint. */
|
|
40
|
+
export const ENV_ENV_VAR = "DEVSPEC_ENV";
|
|
41
|
+
|
|
42
|
+
/** Pi's config dir — honors PI_CODING_AGENT_DIR like pi itself does. */
|
|
43
|
+
export function agentDir(): string {
|
|
44
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Our private corner of the pi config dir. */
|
|
48
|
+
export function devspecDir(): string {
|
|
49
|
+
return join(agentDir(), "devspec");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mcpUrl(): string {
|
|
53
|
+
const override = process.env[URL_ENV_VAR];
|
|
54
|
+
if (override && override.trim()) return override.trim();
|
|
55
|
+
const env = process.env[ENV_ENV_VAR];
|
|
56
|
+
if (env && env.trim().toLowerCase() === "staging") return STAGING_MCP_URL;
|
|
57
|
+
return DEFAULT_MCP_URL;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The Agent setup page matching the effective endpoint (for login guidance). */
|
|
61
|
+
export function agentsSettingsUrl(): string {
|
|
62
|
+
return mcpUrl() === STAGING_MCP_URL ? STAGING_AGENTS_SETTINGS_URL : AGENTS_SETTINGS_URL;
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { BondState } from './bond-state.ts';
|
|
2
|
+
import { repositoryContextMessage } from './repository-context.ts';
|
|
3
|
+
|
|
4
|
+
const fields = ['owner_custom_instructions', 'owner_agent_rules', 'project_custom_instructions', 'project_agent_rules'] as const;
|
|
5
|
+
/** Absent means unchanged; null explicitly clears. Never adopt another project's context. */
|
|
6
|
+
export function applyConnectionContext(bond: BondState, value: unknown): BondState {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return bond;
|
|
8
|
+
const payload = value as Record<string, unknown>;
|
|
9
|
+
if (typeof payload.project_id === 'string' && bond.projectId && payload.project_id !== bond.projectId) throw new Error('Connection context belongs to a different project.');
|
|
10
|
+
const instructions = { ...bond.instructions };
|
|
11
|
+
if (payload.instructions_unchanged !== true) for (const key of fields) {
|
|
12
|
+
if (payload[key] === null) delete instructions[key];
|
|
13
|
+
else if (typeof payload[key] === 'string') instructions[key] = payload[key];
|
|
14
|
+
}
|
|
15
|
+
return { ...bond, instructions,
|
|
16
|
+
...(bond.projectId && Object.hasOwn(payload, 'repository_context') ? {
|
|
17
|
+
repositoryContext: repositoryContextMessage(payload.repository_context, bond.projectId, bond.projectName),
|
|
18
|
+
} : {}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const INSTRUCTION_CONTEXT_MESSAGE = 'devspec-instruction-context';
|
|
23
|
+
/** Full immutable rule snapshot. Changes append at the tail, never rewrite the system prefix. */
|
|
24
|
+
export function instructionContextMessage(bond: BondState): string {
|
|
25
|
+
const instructions = Object.fromEntries(fields.filter(key => key !== 'owner_custom_instructions').map(key => [key,bond.instructions?.[key] ?? null]));
|
|
26
|
+
const json = JSON.stringify({project_id:bond.projectId ?? null,...instructions},null,2).replaceAll('<','\\u003c').replaceAll('>','\\u003e');
|
|
27
|
+
return `DevSpec project and owner instruction snapshot. This replaces earlier values of these fields for this project; null means no instruction in that field. Authority and precedence remain defined by devspec://product/implementation-contract.\n<devspec-instruction-tiers>\n${json}\n</devspec-instruction-tiers>`;
|
|
28
|
+
}
|