@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-command inject hygiene (items aa98098f, 09d4993e).
|
|
3
|
+
*
|
|
4
|
+
* Rebond starts a new pump with no cursor. The first poll is catch-up
|
|
5
|
+
* (already-answered history). If that lap is an empty wake, `synced`
|
|
6
|
+
* must not flip — the next cursor-less poll is still catch-up.
|
|
7
|
+
*
|
|
8
|
+
* Only PRE-ATTACH commands on that lap are history. A live owner
|
|
9
|
+
* command created at/after this bond attached must still be delivered
|
|
10
|
+
* even when it rides the cursor-less window (live miss: Ali 2026-08-13,
|
|
11
|
+
* “can you pick up from here?” swallowed on Rapid Quokka’s first sync).
|
|
12
|
+
*
|
|
13
|
+
* send("now") while the runtime is busy prints
|
|
14
|
+
* "already processing" and is fire-and-forget — we cannot catch it.
|
|
15
|
+
* Never choose `now` unless idle at send time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { OwnerCommand } from "./remote-payload.ts";
|
|
19
|
+
import { sendModeFor, type OwnerDeliveryMode, type OwnerSendMode } from "./preempt.ts";
|
|
20
|
+
|
|
21
|
+
type OwnerCommandIdentity = Pick<OwnerCommand, "id"> & Partial<Pick<OwnerCommand, "text" | "createdAt" | "userName">>;
|
|
22
|
+
|
|
23
|
+
export type BundleReservation = "reserved" | "seen" | "conflict";
|
|
24
|
+
|
|
25
|
+
export class SeenOwnerCommands {
|
|
26
|
+
private readonly ids = new Set<string>();
|
|
27
|
+
private readonly inFlight = new Set<string>();
|
|
28
|
+
|
|
29
|
+
private addSeen(id: string): void {
|
|
30
|
+
// Stable ids are never evicted during this pump generation: cursor state is
|
|
31
|
+
// primary, but replay defense must not become time/volume dependent.
|
|
32
|
+
this.ids.add(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
remember(commands: readonly OwnerCommandIdentity[]): void {
|
|
36
|
+
for (const cmd of commands) this.addSeen(cmd.id);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Reserve all ids in one canonical turn or none of them. */
|
|
40
|
+
reserveBundle(commands: readonly OwnerCommandIdentity[]): BundleReservation {
|
|
41
|
+
if (commands.length === 0) return "conflict";
|
|
42
|
+
const ids = commands.map((command) => command.id);
|
|
43
|
+
const seenCount = ids.filter((id) => this.ids.has(id)).length;
|
|
44
|
+
if (seenCount === ids.length) return "seen";
|
|
45
|
+
if (seenCount > 0 || ids.some((id) => this.inFlight.has(id))) return "conflict";
|
|
46
|
+
for (const id of ids) this.inFlight.add(id);
|
|
47
|
+
return "reserved";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
commitBundle(commands: readonly OwnerCommandIdentity[]): void {
|
|
51
|
+
for (const command of commands) {
|
|
52
|
+
this.inFlight.delete(command.id);
|
|
53
|
+
this.addSeen(command.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
releaseBundle(commands: readonly OwnerCommandIdentity[]): void {
|
|
58
|
+
for (const command of commands) this.inFlight.delete(command.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reserve unseen commands without committing replay state yet. */
|
|
62
|
+
takeUnseen<T extends OwnerCommandIdentity>(commands: readonly T[]): T[] {
|
|
63
|
+
const out: T[] = [];
|
|
64
|
+
for (const cmd of commands) {
|
|
65
|
+
if (this.ids.has(cmd.id) || this.inFlight.has(cmd.id)) continue;
|
|
66
|
+
this.inFlight.add(cmd.id);
|
|
67
|
+
out.push(cmd);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Commit only after hidden context + hydrated command were accepted by Pi. */
|
|
73
|
+
commit(command: Pick<OwnerCommandIdentity, "id">): void {
|
|
74
|
+
this.inFlight.delete(command.id);
|
|
75
|
+
this.addSeen(command.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Release a failure so the unchanged canonical cursor can retry it. */
|
|
79
|
+
release(command: Pick<OwnerCommandIdentity, "id">): void {
|
|
80
|
+
this.inFlight.delete(command.id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
has(id: string): boolean {
|
|
84
|
+
return this.ids.has(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
isInFlight(id: string): boolean {
|
|
88
|
+
return this.inFlight.has(id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get size(): number {
|
|
92
|
+
return this.ids.size;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseTimestampMs(value: unknown): number | null {
|
|
97
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
98
|
+
const ms = Date.parse(value);
|
|
99
|
+
return Number.isNaN(ms) ? null : ms;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Prefer the server attach stamp; otherwise the moment the pump started. */
|
|
103
|
+
export function resolveAttachedAtMs(value: unknown, fallbackMs: number): number {
|
|
104
|
+
return parseTimestampMs(value) ?? fallbackMs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Split the cursor-less catch-up window into already-answered history
|
|
109
|
+
* (created before this bond attached) and live first-touch commands.
|
|
110
|
+
* Missing/unparseable created_at is live — dropping an instruction is worse
|
|
111
|
+
* than a possible replay.
|
|
112
|
+
*/
|
|
113
|
+
export function splitCatchUpCommands<T extends OwnerCommandIdentity>(
|
|
114
|
+
commands: readonly T[],
|
|
115
|
+
attachedAtMs: number,
|
|
116
|
+
): { historical: T[]; live: T[] } {
|
|
117
|
+
const historical: T[] = [];
|
|
118
|
+
const live: T[] = [];
|
|
119
|
+
for (const cmd of commands) {
|
|
120
|
+
const createdMs = parseTimestampMs(cmd.createdAt);
|
|
121
|
+
if (createdMs !== null && createdMs < attachedAtMs) historical.push(cmd);
|
|
122
|
+
else live.push(cmd);
|
|
123
|
+
}
|
|
124
|
+
return { historical, live };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* First poll after rebond: adopt cursors, remember historical ids.
|
|
129
|
+
* An empty wake (no cursor, no commands) is not a completed sync — the
|
|
130
|
+
* next cursor-less poll is still the catch-up window.
|
|
131
|
+
*/
|
|
132
|
+
export function shouldCompleteSync(pkg: {
|
|
133
|
+
cursor?: string;
|
|
134
|
+
ownerCommands: readonly OwnerCommand[];
|
|
135
|
+
}): boolean {
|
|
136
|
+
return Boolean(pkg.cursor) || pkg.ownerCommands.length > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Never `now` unless the runtime is idle right now. */
|
|
140
|
+
export function sendModeAtSendTime(
|
|
141
|
+
mode: OwnerDeliveryMode,
|
|
142
|
+
idleNow: boolean,
|
|
143
|
+
settled = false,
|
|
144
|
+
): OwnerSendMode {
|
|
145
|
+
if (mode === "immediate" && !idleNow) return "steer";
|
|
146
|
+
if (mode === "preempt") return sendModeFor(mode, settled && idleNow);
|
|
147
|
+
return sendModeFor(mode, settled);
|
|
148
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { unwrapMcpResult } from "./result.ts";
|
|
2
|
+
|
|
3
|
+
export type PostSessionReceipt =
|
|
4
|
+
| { status: "stored" | "idempotently_stored"; messageId: string }
|
|
5
|
+
| { status: "skipped" | "rejected" | "malformed" };
|
|
6
|
+
|
|
7
|
+
export interface ExpectedPostReceipt {
|
|
8
|
+
connectionId: string;
|
|
9
|
+
sessionId?: string | null;
|
|
10
|
+
phase: string;
|
|
11
|
+
turnKind?: string;
|
|
12
|
+
/** Source-aware delivery must not accept a different row's legacy receipt. */
|
|
13
|
+
assistantSourceId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
18
|
+
? value as Record<string, unknown> : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Accept deployed text-JSON and structured MCP envelopes, never transport success alone. */
|
|
22
|
+
export function parsePostSessionReceipt(raw: unknown, expected: ExpectedPostReceipt): PostSessionReceipt {
|
|
23
|
+
const { details, isError } = unwrapMcpResult(raw);
|
|
24
|
+
if (isError) return { status: "rejected" };
|
|
25
|
+
const data = record(details);
|
|
26
|
+
if (!data) return { status: "malformed" };
|
|
27
|
+
const receipt = record(data.delivery);
|
|
28
|
+
if (receipt?.status === "rejected") return { status: "rejected" };
|
|
29
|
+
if (data.skipped === true || data.noop === true || receipt?.status === "skipped") return { status: "skipped" };
|
|
30
|
+
if (data.error !== undefined) return { status: "rejected" };
|
|
31
|
+
const expectedSessionId = expected.sessionId ?? null;
|
|
32
|
+
if ((data.session_id ?? null) !== expectedSessionId || data.phase !== expected.phase ||
|
|
33
|
+
data.connection_id !== expected.connectionId ||
|
|
34
|
+
(expected.turnKind !== undefined && data.turn_kind !== expected.turnKind) ||
|
|
35
|
+
typeof data.message_id !== "string" || !data.message_id.trim()) return { status: "malformed" };
|
|
36
|
+
if (expected.assistantSourceId !== undefined &&
|
|
37
|
+
(data.assistant_source_id !== expected.assistantSourceId || !receipt)) return { status: "malformed" };
|
|
38
|
+
if (data.delivery !== undefined) {
|
|
39
|
+
if (!receipt || (receipt.status !== "stored" && receipt.status !== "idempotently_stored") ||
|
|
40
|
+
receipt.message_id !== data.message_id) return { status: "malformed" };
|
|
41
|
+
return { status: receipt.status, messageId: data.message_id };
|
|
42
|
+
}
|
|
43
|
+
// Legacy answer (including local_prompt) and trail successes all include the
|
|
44
|
+
// persisted row id, session, connection and phase. Bare noop is NOT idempotence.
|
|
45
|
+
return { status: "stored", messageId: data.message_id };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A cumulative snapshot stays dirty until acknowledged. Failure stops automatic
|
|
49
|
+
* replay for this segment: stale authority and ambiguous response loss are not retry permission. */
|
|
50
|
+
export class TrailDeliveryState {
|
|
51
|
+
private revision = 0;
|
|
52
|
+
private acknowledged = 0;
|
|
53
|
+
private pending: number | null = null;
|
|
54
|
+
private failed = false;
|
|
55
|
+
|
|
56
|
+
markDirty(): void { this.revision += 1; }
|
|
57
|
+
get dirty(): boolean { return this.revision !== this.acknowledged; }
|
|
58
|
+
get blocked(): boolean { return this.failed || this.pending !== null; }
|
|
59
|
+
begin(): number | null {
|
|
60
|
+
if (!this.dirty || this.blocked) return null;
|
|
61
|
+
this.pending = this.revision;
|
|
62
|
+
return this.pending;
|
|
63
|
+
}
|
|
64
|
+
finish(snapshot: number, stored: boolean): void {
|
|
65
|
+
if (this.pending !== snapshot) return;
|
|
66
|
+
this.pending = null;
|
|
67
|
+
if (stored) this.acknowledged = snapshot;
|
|
68
|
+
else this.failed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-instruction preemption policy (item f5bbc8f8).
|
|
3
|
+
*
|
|
4
|
+
* Arrival of an owner command mid-turn is a LIFECYCLE event, not content
|
|
5
|
+
* authorship: abort the running turn (pause, never undo), then deliver the
|
|
6
|
+
* owner message. Ambient/room noise never reaches this function — the pump
|
|
7
|
+
* only calls it for ownerCommands.
|
|
8
|
+
*
|
|
9
|
+
* Pure so the three modes are unit-testable without a live pi session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type OwnerDeliveryMode = "immediate" | "queue" | "preempt";
|
|
13
|
+
|
|
14
|
+
/** How sendUserMessage must be called for that mode. */
|
|
15
|
+
export type OwnerSendMode = "now" | "steer" | "followUp";
|
|
16
|
+
|
|
17
|
+
export function decideOwnerDelivery(input: {
|
|
18
|
+
idle: boolean;
|
|
19
|
+
preemptEnabled: boolean;
|
|
20
|
+
hasOwnerCommand: boolean;
|
|
21
|
+
}): OwnerDeliveryMode {
|
|
22
|
+
if (!input.hasOwnerCommand) return "immediate";
|
|
23
|
+
if (input.idle) return "immediate";
|
|
24
|
+
if (input.preemptEnabled) return "preempt";
|
|
25
|
+
return "queue";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How a directed-question answer should meet a running Pi turn (item 4221d08e). */
|
|
29
|
+
export type InteractionDeliveryMode = "apply" | "preempt" | "defer";
|
|
30
|
+
|
|
31
|
+
export function decideInteractionDelivery(input: {
|
|
32
|
+
idle: boolean;
|
|
33
|
+
preemptEnabled: boolean;
|
|
34
|
+
hasEvent: boolean;
|
|
35
|
+
}): InteractionDeliveryMode {
|
|
36
|
+
if (!input.hasEvent) return "defer";
|
|
37
|
+
if (input.idle) return "apply";
|
|
38
|
+
if (input.preemptEnabled) return "preempt";
|
|
39
|
+
return "defer";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* After abort(), isStreaming stays true until the aborted run settles
|
|
44
|
+
* (cooperative AbortSignal). A bare send during that window throws
|
|
45
|
+
* "already processing" (live miss 1). Steering into the SAME run then
|
|
46
|
+
* produces a terminal answer that agent_settled never posts — settle
|
|
47
|
+
* nulls turn state first (live miss 2, Ali 2026-08-13: interrupt worked,
|
|
48
|
+
* reply stayed in the terminal).
|
|
49
|
+
*
|
|
50
|
+
* Wait for idle, then send as a fresh turn (`now`). If the wait times
|
|
51
|
+
* out, fall back to steer so the message is not dropped.
|
|
52
|
+
*/
|
|
53
|
+
export function sendModeFor(mode: OwnerDeliveryMode, settled = false): OwnerSendMode {
|
|
54
|
+
if (mode === "preempt") return settled ? "now" : "steer";
|
|
55
|
+
if (mode === "queue") return "followUp";
|
|
56
|
+
return "now";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function waitUntil(
|
|
60
|
+
check: () => boolean,
|
|
61
|
+
opts: {
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
intervalMs: number;
|
|
64
|
+
now?: () => number;
|
|
65
|
+
sleep?: (ms: number) => Promise<void>;
|
|
66
|
+
},
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
const now = opts.now ?? Date.now;
|
|
69
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
70
|
+
const deadline = now() + opts.timeoutMs;
|
|
71
|
+
while (!check()) {
|
|
72
|
+
if (now() >= deadline) return false;
|
|
73
|
+
await sleep(opts.intervalMs);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Stranded-busy recovery (item 3f07f97b). pi_abort() is cooperative: when the
|
|
80
|
+
* aborted run never settles, the settle wait times out while the model is
|
|
81
|
+
* already idle and nothing holds occupancy — yet active.busy stays true, so
|
|
82
|
+
* every later lap defers the same interaction event ("awaiting idle") and the
|
|
83
|
+
* event-only lane starves all command delivery (live wedge 2026-09-18, 73
|
|
84
|
+
* minutes). Clearing is safe exactly when nothing is running: a genuinely
|
|
85
|
+
* settling run keeps either ctx or occupancy busy. Deliberately not gated on
|
|
86
|
+
* background tasks: a stale pending entry must not preserve the wedge, and the
|
|
87
|
+
* applied event starts a run that reasserts busy within one lap.
|
|
88
|
+
*/
|
|
89
|
+
export function shouldClearStrandedBusyAfterAbort(input: {
|
|
90
|
+
settled: boolean;
|
|
91
|
+
ctxIdle: boolean;
|
|
92
|
+
occupancyBusy: boolean;
|
|
93
|
+
pumpBusy: boolean;
|
|
94
|
+
}): boolean {
|
|
95
|
+
return !input.settled && input.ctxIdle && !input.occupancyBusy && input.pumpBusy;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Tail note so the model judges stop-vs-continue. Not an instruction to invent work. */
|
|
99
|
+
export const PREEMPT_TAIL =
|
|
100
|
+
"[This interrupted a running turn. Answer this first. Then continue the prior work if that is appropriate — a short ack is enough to stop; do not throw the previous context away.]";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Conversation-owned project intent, separate from committable folder defaults. */
|
|
2
|
+
export const PROJECT_CHOICE_ENTRY = "devspec-project-choice-v1";
|
|
3
|
+
export interface ProjectCandidate {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
organization?: { id: string; name: string };
|
|
7
|
+
}
|
|
8
|
+
export interface ProjectChoice {
|
|
9
|
+
version: 1;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
project: ProjectCandidate;
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProjectChoiceRequired {
|
|
15
|
+
version: 1;
|
|
16
|
+
status: "choice_required";
|
|
17
|
+
reason: string;
|
|
18
|
+
candidates: ProjectCandidate[];
|
|
19
|
+
}
|
|
20
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
const object = (value: unknown): Record<string, unknown> | null =>
|
|
22
|
+
value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
23
|
+
|
|
24
|
+
export function readProjectCandidate(value: unknown): ProjectCandidate | null {
|
|
25
|
+
const row = object(value);
|
|
26
|
+
if (!row || typeof row.id !== "string" || !UUID.test(row.id) || typeof row.name !== "string") return null;
|
|
27
|
+
const org = object(row.organization);
|
|
28
|
+
return { id: row.id, name: row.name, ...(org && typeof org.id === "string" && UUID.test(org.id) && typeof org.name === "string"
|
|
29
|
+
? { organization: { id: org.id, name: org.name } } : {}) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read structured MCP data or its JSON text equivalent, never English error prose. */
|
|
33
|
+
export function projectResultData(raw: unknown): Record<string, unknown> | null {
|
|
34
|
+
const row = object(raw);
|
|
35
|
+
if (!row) return null;
|
|
36
|
+
if (object(row.structuredContent)) return object(row.structuredContent);
|
|
37
|
+
if (Array.isArray(row.content)) for (const block of row.content) {
|
|
38
|
+
const text = object(block);
|
|
39
|
+
if (text?.type !== "text" || typeof text.text !== "string") continue;
|
|
40
|
+
try { const parsed = object(JSON.parse(text.text)); if (parsed) return parsed; } catch { /* legacy plain message */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function readProjectChoiceRequired(raw: unknown): ProjectChoiceRequired | null {
|
|
45
|
+
const data = projectResultData(raw);
|
|
46
|
+
const selection = object(data?.project_selection);
|
|
47
|
+
if (data?.code !== "project_choice_required" || selection?.version !== 1 || selection.status !== "choice_required" || !Array.isArray(selection.candidates)) return null;
|
|
48
|
+
const candidates = selection.candidates.map(readProjectCandidate);
|
|
49
|
+
if (candidates.some(p => !p || !p.organization)) return null;
|
|
50
|
+
return { version: 1, status: "choice_required", reason: String(selection.reason ?? "project_required"), candidates: candidates as ProjectCandidate[] };
|
|
51
|
+
}
|
|
52
|
+
export function projectFromRegistration(data: Record<string, unknown>, endpoint: string, fallback?: ProjectCandidate): ProjectChoice | null {
|
|
53
|
+
const selection = object(data.project_selection);
|
|
54
|
+
const project = selection?.version === 1 && selection.status === "resolved" ? readProjectCandidate(selection.project) : null;
|
|
55
|
+
const id = typeof data.project_id === "string" && UUID.test(data.project_id) ? data.project_id : null;
|
|
56
|
+
if (!id || (project && project.id !== id)) return null;
|
|
57
|
+
return { version: 1, endpoint, project: project ?? (fallback?.id === id ? fallback : { id, name: id }), source: typeof selection?.source === "string" ? selection.source : "conversation" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ChoiceStore { localId: string | null; choice: ProjectChoice | null; launchConsumed: boolean; error: string | null }
|
|
61
|
+
const KEY = "__devspecPiConversationProject";
|
|
62
|
+
function store(): ChoiceStore {
|
|
63
|
+
const global = globalThis as unknown as Record<string, ChoiceStore | undefined>;
|
|
64
|
+
return global[KEY] ??= { localId: null, choice: null, launchConsumed: false, error: null };
|
|
65
|
+
}
|
|
66
|
+
export function getProjectChoice(localId?: string): ProjectChoice | null {
|
|
67
|
+
return localId && store().localId !== localId ? null : store().choice;
|
|
68
|
+
}
|
|
69
|
+
export function consumeProjectLaunchFlag(): boolean {
|
|
70
|
+
const first = !store().launchConsumed;
|
|
71
|
+
store().launchConsumed = true;
|
|
72
|
+
return first;
|
|
73
|
+
}
|
|
74
|
+
export function setProjectChoice(localId: string, choice: ProjectChoice | null): void {
|
|
75
|
+
store().localId = localId; store().choice = choice; store().error = null;
|
|
76
|
+
}
|
|
77
|
+
export function blockProjectChoice(localId: string, error: string): void {
|
|
78
|
+
store().localId = localId; store().error = error;
|
|
79
|
+
}
|
|
80
|
+
export function restoreProjectChoice(entries: readonly unknown[], localId: string, endpoint: string): ProjectChoice | null {
|
|
81
|
+
let restored: ProjectChoice | null = null;
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const row = object(entry);
|
|
84
|
+
if (row?.type !== "custom" || row.customType !== PROJECT_CHOICE_ENTRY) continue;
|
|
85
|
+
const data = object(row.data);
|
|
86
|
+
const project = readProjectCandidate(data?.project);
|
|
87
|
+
if (data?.version === 1 && data.endpoint === endpoint && project && typeof data.source === "string") {
|
|
88
|
+
restored = { version: 1, endpoint, project, source: data.source };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
setProjectChoice(localId, restored);
|
|
92
|
+
return restored;
|
|
93
|
+
}
|
|
94
|
+
export function hasConversationProjectScope(): boolean { return Boolean(store().choice || store().error); }
|
|
95
|
+
export function conversationScope(args: Record<string, unknown>, endpoint: string): Record<string, unknown> {
|
|
96
|
+
if (store().error && !args.project_id) throw new Error(store().error!);
|
|
97
|
+
const choice = getProjectChoice();
|
|
98
|
+
// Discovery is still account-wide; explicit/entity identifiers are validated server-side.
|
|
99
|
+
return choice?.endpoint === endpoint && !args.project_id ? { ...args, project_id: choice.project.id } : args;
|
|
100
|
+
}
|
|
101
|
+
const terminalText = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
102
|
+
export function projectLabel(project: ProjectCandidate): string {
|
|
103
|
+
return `${terminalText(project.name)} — ${terminalText(project.organization?.name ?? "organisation unavailable")}`;
|
|
104
|
+
}
|
|
105
|
+
/** IDs are exceptional disambiguators, not decoration on every choice. */
|
|
106
|
+
export function projectChoiceLabels(projects: readonly ProjectCandidate[]): string[] {
|
|
107
|
+
const labels = projects.map(projectLabel);
|
|
108
|
+
return labels.map((label, index) => labels.filter(value => value === label).length > 1
|
|
109
|
+
? `${label} · ${projects[index]!.id}` : label);
|
|
110
|
+
}
|
|
111
|
+
export function projectDetails(choice: ProjectChoice): string {
|
|
112
|
+
const sources: Record<string, string> = {
|
|
113
|
+
git_remote: "Repository match", folder_pin: "Folder default", conversation: "Saved conversation choice",
|
|
114
|
+
explicit: "Chosen for this conversation", connection: "Existing connection", single_project: "Only accessible project", entity: "Selected record",
|
|
115
|
+
};
|
|
116
|
+
return `Project: ${terminalText(choice.project.name)}\nOrganisation: ${terminalText(choice.project.organization?.name ?? "Not reported")}\nProject ID: ${choice.project.id}\nSelected by: ${sources[choice.source] ?? "Not reported"}`;
|
|
117
|
+
}
|
|
118
|
+
export function matchingProjects(projects: readonly ProjectCandidate[], selector: string): ProjectCandidate[] {
|
|
119
|
+
const value = selector.trim().toLowerCase();
|
|
120
|
+
const ids = projects.filter(p => p.id.toLowerCase() === value);
|
|
121
|
+
return ids.length ? ids : projects.filter(p => p.name.toLowerCase() === value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Extract only our flag, leaving the existing room parser's vocabulary untouched. */
|
|
125
|
+
export function projectRemoteArgs(input: string): { selector?: string; remaining: string; error?: string } {
|
|
126
|
+
const tokens: string[] = [];
|
|
127
|
+
let current = "", quote = "", inToken = false;
|
|
128
|
+
for (const char of input.trim()) {
|
|
129
|
+
if (quote) { if (char === quote) quote = ""; else current += char; inToken = true; }
|
|
130
|
+
else if (char === '"' || char === "'") { quote = char; inToken = true; }
|
|
131
|
+
else if (/\s/.test(char)) { if (inToken) { tokens.push(current); current = ""; inToken = false; } }
|
|
132
|
+
else { current += char; inToken = true; }
|
|
133
|
+
}
|
|
134
|
+
if (quote) return { remaining: "", error: "Close the quoted project name before connecting." };
|
|
135
|
+
if (inToken) tokens.push(current);
|
|
136
|
+
let selector: string | undefined;
|
|
137
|
+
const remaining: string[] = [];
|
|
138
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
139
|
+
if (tokens[i] === "--project" || tokens[i] === "--devspec-project") {
|
|
140
|
+
if (selector !== undefined || !tokens[i + 1]?.trim() || tokens[i + 1]!.startsWith("--")) return { remaining: "", error: "Pass one project name or ID after --project." };
|
|
141
|
+
selector = tokens[++i];
|
|
142
|
+
} else remaining.push(tokens[i]!);
|
|
143
|
+
}
|
|
144
|
+
return { selector, remaining: remaining.join(" ") };
|
|
145
|
+
}
|