@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,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — project scoping via git remote, or a folder pin when there is no repo.
|
|
3
|
+
*
|
|
4
|
+
* DevSpec tokens are account-wide; the server resolves which project a call
|
|
5
|
+
* belongs to. Hosts send folder facts — this checkout's `git remote get-url origin`
|
|
6
|
+
* as `git_remote`, and/or `.devspec/project.json` as `pinned_project_id` — and
|
|
7
|
+
* the SERVER ranks them. The client never decides precedence and never guesses.
|
|
8
|
+
*
|
|
9
|
+
* When the caller passes `cwd`, those facts go on the first request (Claude-shaped),
|
|
10
|
+
* so a multi-project token can auto-bond without a round-trip that asks which
|
|
11
|
+
* project. If the first call was still unscoped and the server asks, one retry
|
|
12
|
+
* attaches the same facts — same client, extra args, not a reconnect.
|
|
13
|
+
*
|
|
14
|
+
* A folder with NO repo has no remote to send. The greenfield case — a project
|
|
15
|
+
* planned in DevSpec before its code exists — names the project with a pin file:
|
|
16
|
+
* `.devspec/project.json` holding `{ "project_id": "<uuid>" }`.
|
|
17
|
+
*
|
|
18
|
+
* The pin is sent as `pinned_project_id`, NEVER as `project_id`. They are not
|
|
19
|
+
* interchangeable: `project_id` is an explicit override that outranks a verified
|
|
20
|
+
* git remote, whereas the pin is only a local assertion and the server deliberately
|
|
21
|
+
* ranks it BELOW a remote it can verify — so a pin copied in with a template
|
|
22
|
+
* self-corrects instead of hijacking the folder. Both signals go together when
|
|
23
|
+
* both exist; the SERVER arbitrates.
|
|
24
|
+
*
|
|
25
|
+
* Remote and pin identity are read fresh; only server-verified jurisdiction results
|
|
26
|
+
* receive bounded endpoint/token/repository-aware caching in the nudge layer.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
34
|
+
|
|
35
|
+
/** Does this server error mean "tell me which project this checkout is"? */
|
|
36
|
+
export function needsProjectScope(message: string): boolean {
|
|
37
|
+
return /multiple (accessible )?projects|could not (resolve|determine).*project|unable to (resolve|determine).*project|no project.*(resolved|context)|project.*ambiguous|needs a project|more than one|pass (project_id|git_remote)/i.test(
|
|
38
|
+
message,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Credential-free bounded remote suitable for DevSpec transport, or null. */
|
|
43
|
+
export function sanitizeGitRemoteForTransport(remote: string | null): string | null {
|
|
44
|
+
if (!remote) return null;
|
|
45
|
+
const value = remote.trim();
|
|
46
|
+
if (!value || value.length > 512 || /[\s?#\u0000-\u001f\u007f]/u.test(value)) return null;
|
|
47
|
+
const scp = /^([^/@:]+)@([^/:]+):(.+)$/u.exec(value);
|
|
48
|
+
if (scp && scp[1] !== "git") return null;
|
|
49
|
+
if (/^https?:\/\//iu.test(value)) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(value);
|
|
52
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) return null;
|
|
53
|
+
} catch { return null; }
|
|
54
|
+
} else if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(value)) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(value);
|
|
57
|
+
const safeSshUser = parsed.protocol.endsWith("ssh:") && parsed.username === "git";
|
|
58
|
+
if (parsed.password || parsed.search || parsed.hash || (parsed.username && !safeSshUser)) return null;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** This checkout's credential-free `origin` URL, or null. */
|
|
65
|
+
export function resolveGitRemote(cwd: string): Promise<string | null> {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
execFile(
|
|
68
|
+
"git",
|
|
69
|
+
["remote", "get-url", "origin"],
|
|
70
|
+
{ cwd, timeout: 3000 },
|
|
71
|
+
(err, stdout) => {
|
|
72
|
+
const url = !err && stdout ? stdout.trim() : null;
|
|
73
|
+
resolve(sanitizeGitRemoteForTransport(url));
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Test hook. */
|
|
80
|
+
export function clearGitRemoteCache(): void {
|
|
81
|
+
// Compatibility test hook. Repository identity is intentionally read fresh.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run one bounded Git metadata query. Every unavailable/error state is null. */
|
|
85
|
+
function gitOutput(cwd: string, args: string[]): Promise<string | null> {
|
|
86
|
+
return new Promise((done) => {
|
|
87
|
+
execFile("git", args, { cwd, timeout: 3000 }, (err, stdout) => {
|
|
88
|
+
done(!err && stdout ? stdout : null);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The repository's main working tree, shared by every linked worktree, or null.
|
|
95
|
+
*
|
|
96
|
+
* Git's absolute common directory identifies the repository. Its porcelain
|
|
97
|
+
* worktree metadata then names the actual main checkout without guessing that
|
|
98
|
+
* the common directory must be `<main-worktree>/.git` — an assumption that is
|
|
99
|
+
* false for bare repositories and `--separate-git-dir` layouts.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveRepositoryMainWorktree(cwd: string): Promise<string | null> {
|
|
102
|
+
const commonDirectory = await gitOutput(cwd, [
|
|
103
|
+
"rev-parse",
|
|
104
|
+
"--path-format=absolute",
|
|
105
|
+
"--git-common-dir",
|
|
106
|
+
]);
|
|
107
|
+
if (!commonDirectory?.trim()) return null;
|
|
108
|
+
const absoluteCommonDirectory = resolve(commonDirectory.trim());
|
|
109
|
+
if (basename(absoluteCommonDirectory) !== ".git") return null;
|
|
110
|
+
|
|
111
|
+
const listing = await gitOutput(cwd, ["worktree", "list", "--porcelain", "-z"]);
|
|
112
|
+
if (!listing) return null;
|
|
113
|
+
|
|
114
|
+
const firstRecord = listing.split("\0\0", 1)[0];
|
|
115
|
+
if (!firstRecord) return null;
|
|
116
|
+
const fields = firstRecord.split("\0");
|
|
117
|
+
if (fields.includes("bare")) return null;
|
|
118
|
+
const worktree = fields.find((field) => field.startsWith("worktree "));
|
|
119
|
+
if (!worktree) return null;
|
|
120
|
+
const mainWorktree = resolve(worktree.slice("worktree ".length));
|
|
121
|
+
return mainWorktree === dirname(absoluteCommonDirectory) ? mainWorktree : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read one directory's pin. Null for every failure mode — see resolveProjectPin. */
|
|
125
|
+
function readPinAt(dir: string): Promise<string | null> {
|
|
126
|
+
return readFile(join(dir, ".devspec", "project.json"), "utf8")
|
|
127
|
+
.then((raw) => {
|
|
128
|
+
const parsed: unknown = JSON.parse(raw);
|
|
129
|
+
const id =
|
|
130
|
+
parsed && typeof parsed === "object"
|
|
131
|
+
? (parsed as { project_id?: unknown }).project_id
|
|
132
|
+
: undefined;
|
|
133
|
+
return typeof id === "string" && id.trim().length > 0 ? id.trim() : null;
|
|
134
|
+
})
|
|
135
|
+
.catch(() => null);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The directories to check, nearest first (ADR dd0c1cff D12).
|
|
140
|
+
*
|
|
141
|
+
* We walk UP because "workspace root" and "current directory" are the same folder right
|
|
142
|
+
* up until someone launches an agent from a subdirectory — routine in a monorepo. A pin
|
|
143
|
+
* that works from the root and silently fails one level down is worse than no pin,
|
|
144
|
+
* because the user believes the folder is anchored.
|
|
145
|
+
*
|
|
146
|
+
* Two bounds, and neither is an arbitrary depth cap — each is a real boundary:
|
|
147
|
+
* - the git repository root (inclusive): a repo is one tree with one identity, and a
|
|
148
|
+
* pin must never resolve from outside the repository that contains it;
|
|
149
|
+
* - $HOME (exclusive): `~/.devspec` is the MACHINE-state directory — connections,
|
|
150
|
+
* bonds, poller pids. A project.json at or above home would silently capture every
|
|
151
|
+
* folder the user owns, which is exactly the guessing 01892cce forbids.
|
|
152
|
+
*/
|
|
153
|
+
function isAtOrAbove(dir: string, boundary: string): boolean {
|
|
154
|
+
const fromDir = relative(resolve(dir), resolve(boundary));
|
|
155
|
+
return (
|
|
156
|
+
fromDir === "" ||
|
|
157
|
+
(fromDir !== ".." && !fromDir.startsWith(`..${sep}`) && !isAbsolute(fromDir))
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pinSearchDirs(cwd: string, home: string): string[] {
|
|
162
|
+
const dirs: string[] = [];
|
|
163
|
+
let dir = resolve(cwd);
|
|
164
|
+
const stopAbove = resolve(home);
|
|
165
|
+
|
|
166
|
+
for (;;) {
|
|
167
|
+
// Never read AT or above home — that is where machine state lives, not projects.
|
|
168
|
+
// A checkout outside home (/srv/app) never meets this and stops at .git or /.
|
|
169
|
+
if (dir === stopAbove) break;
|
|
170
|
+
|
|
171
|
+
dirs.push(dir);
|
|
172
|
+
|
|
173
|
+
// A repository root is the outermost directory a pin may come from.
|
|
174
|
+
if (existsSync(join(dir, ".git"))) break;
|
|
175
|
+
|
|
176
|
+
const parent = dirname(dir);
|
|
177
|
+
if (parent === dir) break; // filesystem root
|
|
178
|
+
dir = parent;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return dirs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* This folder's DevSpec project pin — the nearest `.devspec/project.json`'s `project_id`
|
|
186
|
+
* walking up from `cwd`, or null. Cached per cwd, like the remote.
|
|
187
|
+
*
|
|
188
|
+
* Every failure mode is a plain null: no file, unreadable, malformed JSON, or a
|
|
189
|
+
* `project_id` that isn't a non-empty string. A pin is a convenience for resolving a
|
|
190
|
+
* folder that has no repo; a broken one must never surface as an error on an unrelated
|
|
191
|
+
* call, and must never be sent as a guess.
|
|
192
|
+
*/
|
|
193
|
+
export interface ProjectPinFile { path: string; projectId: string }
|
|
194
|
+
|
|
195
|
+
export async function resolveProjectPin(cwd: string, home: string = homedir()): Promise<string | null> {
|
|
196
|
+
return (await resolveProjectPinFile(cwd, home))?.projectId ?? null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Return the effective file as well as its value, so management never guesses a path. */
|
|
200
|
+
export function resolveProjectPinFile(
|
|
201
|
+
cwd: string,
|
|
202
|
+
home: string = homedir(),
|
|
203
|
+
): Promise<ProjectPinFile | null> {
|
|
204
|
+
const dirs = pinSearchDirs(cwd, home);
|
|
205
|
+
|
|
206
|
+
// Nearest wins, so an inner folder can deliberately override an outer one.
|
|
207
|
+
// A linked worktree's cwd chain ends at its `.git` pointer, not at the main
|
|
208
|
+
// checkout. Add the repository's main working tree as the final candidate so
|
|
209
|
+
// repository-local jurisdiction follows Git identity across every worktree.
|
|
210
|
+
const walk = async (): Promise<ProjectPinFile | null> => {
|
|
211
|
+
for (const dir of dirs) {
|
|
212
|
+
const id = await readPinAt(dir);
|
|
213
|
+
if (id) return { path: join(dir, ".devspec", "project.json"), projectId: id };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const repositoryRoot = await resolveRepositoryMainWorktree(cwd);
|
|
217
|
+
if (
|
|
218
|
+
repositoryRoot &&
|
|
219
|
+
!dirs.includes(repositoryRoot) &&
|
|
220
|
+
!isAtOrAbove(repositoryRoot, home) &&
|
|
221
|
+
pinSearchDirs(repositoryRoot, home)[0] === repositoryRoot
|
|
222
|
+
) {
|
|
223
|
+
const id = await readPinAt(repositoryRoot);
|
|
224
|
+
return id ? { path: join(repositoryRoot, ".devspec", "project.json"), projectId: id } : null;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return walk();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** True when the caller already named a project (explicit, remote, or pin). */
|
|
233
|
+
export function argsHaveProjectScope(args: Record<string, unknown>): boolean {
|
|
234
|
+
return Boolean(args.git_remote || args.pinned_project_id || args.project_id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Folder facts for one MCP call. Server ranks them; never send the pin as `project_id`.
|
|
239
|
+
*/
|
|
240
|
+
export async function folderScopeArgs(cwd: string): Promise<{
|
|
241
|
+
git_remote?: string;
|
|
242
|
+
pinned_project_id?: string;
|
|
243
|
+
}> {
|
|
244
|
+
const [remote, pin] = await Promise.all([
|
|
245
|
+
resolveGitRemote(cwd),
|
|
246
|
+
resolveProjectPin(cwd),
|
|
247
|
+
]);
|
|
248
|
+
return {
|
|
249
|
+
...(remote ? { git_remote: remote } : {}),
|
|
250
|
+
...(pin ? { pinned_project_id: pin } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Remove only the effective default the human confirmed, never a newly changed value. */
|
|
255
|
+
export async function forgetProjectPin(pin: ProjectPinFile): Promise<void> {
|
|
256
|
+
const current = await readPinAt(dirname(dirname(pin.path)));
|
|
257
|
+
if (current !== pin.projectId) throw new Error("The folder default changed. Review it again before forgetting it.");
|
|
258
|
+
await unlink(pin.path);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Test hook. */
|
|
262
|
+
export function clearProjectPinCache(): void {
|
|
263
|
+
// Compatibility test hook. Pins are intentionally read fresh.
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** What a pin write did, so the caller can tell the user precisely what changed. */
|
|
267
|
+
export interface PinWriteResult {
|
|
268
|
+
/** Absolute path of the file written. */
|
|
269
|
+
path: string;
|
|
270
|
+
/** The project id now pinned. */
|
|
271
|
+
projectId: string;
|
|
272
|
+
/** The id this replaced, when the folder was already pinned to something else. */
|
|
273
|
+
replaced: string | null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Where a pin BELONGS for this folder (ADR dd0c1cff D13) — the mirror of the read walk.
|
|
278
|
+
*
|
|
279
|
+
* Inside a git repo it is the repository ROOT, because that is the only location the read
|
|
280
|
+
* walk reaches from every subdirectory (it stops at the root), and it is where a
|
|
281
|
+
* *committed* pin belongs — the whole teammate-onboarding benefit. Writing at the current
|
|
282
|
+
* directory instead would leave a stray `.devspec/` halfway up the tree that resolves for
|
|
283
|
+
* one subtree and not its siblings.
|
|
284
|
+
*
|
|
285
|
+
* With no repo — the greenfield case this exists for — the working directory IS the
|
|
286
|
+
* project folder, so that is the target.
|
|
287
|
+
*/
|
|
288
|
+
export function pinTargetDir(cwd: string, home: string = homedir()): string {
|
|
289
|
+
const dirs = pinSearchDirs(cwd, home);
|
|
290
|
+
if (dirs.length === 0) return resolve(cwd);
|
|
291
|
+
|
|
292
|
+
const outermost = dirs[dirs.length - 1] as string;
|
|
293
|
+
// pinSearchDirs stops AT a repository root, so the last entry is the repo root when
|
|
294
|
+
// there is one. Otherwise it is wherever the walk ran out, and cwd is the honest
|
|
295
|
+
// answer — we must not pin a parent we only passed through.
|
|
296
|
+
return existsSync(join(outermost, ".git")) ? outermost : resolve(cwd);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Write this folder's pin. Returns what changed; throws only if the filesystem does.
|
|
301
|
+
*
|
|
302
|
+
* Deliberately does NOT decide whether writing is appropriate — that is the caller's job,
|
|
303
|
+
* and the rules are in D9/D13/D14: explicit association or remember consent only,
|
|
304
|
+
* never as a side effect of connecting, and a re-pin is stated before it happens. This
|
|
305
|
+
* function reports `replaced` so the caller can say what it is about to overwrite rather
|
|
306
|
+
* than repointing a whole team's committed folder on one person's say-so.
|
|
307
|
+
*
|
|
308
|
+
* `projectName` is written for human legibility only and is explicitly NON-authoritative —
|
|
309
|
+
* readers ignore it (see readPinAt, which reads `project_id` alone). It exists so someone
|
|
310
|
+
* reviewing the diff knows what the uuid is. Nothing else may go in this file: no path, no
|
|
311
|
+
* hostname, no user, no token, no timestamp. That rule is what keeps the pin safe to
|
|
312
|
+
* commit by construction (D10).
|
|
313
|
+
*/
|
|
314
|
+
export async function writeProjectPin(
|
|
315
|
+
cwd: string,
|
|
316
|
+
projectId: string,
|
|
317
|
+
projectName?: string,
|
|
318
|
+
home: string = homedir(),
|
|
319
|
+
): Promise<PinWriteResult> {
|
|
320
|
+
const id = projectId.trim();
|
|
321
|
+
if (!id) throw new Error("writeProjectPin: projectId is required");
|
|
322
|
+
|
|
323
|
+
const dir = pinTargetDir(cwd, home);
|
|
324
|
+
const replaced = await readPinAt(dir);
|
|
325
|
+
|
|
326
|
+
const body: { project_id: string; project_name?: string } = { project_id: id };
|
|
327
|
+
const name = projectName?.trim();
|
|
328
|
+
if (name) body.project_name = name;
|
|
329
|
+
|
|
330
|
+
await mkdir(join(dir, ".devspec"), { recursive: true });
|
|
331
|
+
const path = join(dir, ".devspec", "project.json");
|
|
332
|
+
await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
333
|
+
|
|
334
|
+
return { path, projectId: id, replaced: replaced === id ? null : replaced };
|
|
335
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
3
|
+
import { resolveEndpoint, resolveToken } from "./auth-store.ts";
|
|
4
|
+
import { callDevspec } from "./client.ts";
|
|
5
|
+
import { getBond } from "./bond-state.ts";
|
|
6
|
+
import { resolveProjectPinFile, forgetProjectPin, pinTargetDir, writeProjectPin } from "./project-scope.ts";
|
|
7
|
+
import {
|
|
8
|
+
PROJECT_CHOICE_ENTRY, getProjectChoice, setProjectChoice, restoreProjectChoice, consumeProjectLaunchFlag, blockProjectChoice,
|
|
9
|
+
projectLabel, projectChoiceLabels, projectDetails, matchingProjects, readProjectCandidate, projectResultData,
|
|
10
|
+
type ProjectCandidate, type ProjectChoice, type ProjectChoiceRequired,
|
|
11
|
+
} from "./project-choice.ts";
|
|
12
|
+
|
|
13
|
+
export interface ProjectBondOutcome {
|
|
14
|
+
ok: boolean; error?: string; errorKind?: "auth" | "api"; codename?: string; attached?: boolean; cancelled?: boolean;
|
|
15
|
+
projectChoice?: ProjectChoiceRequired;
|
|
16
|
+
}
|
|
17
|
+
export interface ProjectSelectionDependencies {
|
|
18
|
+
attempt(ctx: ExtensionContext, room?: string, project?: ProjectCandidate): Promise<ProjectBondOutcome>;
|
|
19
|
+
stop(ctx: ExtensionContext): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Native UI only; selection and transport logic remain testable without a model. */
|
|
23
|
+
export function installProjectSelection(pi: ExtensionAPI, deps: ProjectSelectionDependencies) {
|
|
24
|
+
pi.registerFlag("devspec-project", { type: "string", description: "Use a DevSpec project name or ID for this local conversation." });
|
|
25
|
+
let connecting = false;
|
|
26
|
+
let explicitConnect = false;
|
|
27
|
+
const CONNECT_ONCE_ENTRY = "devspec-project-connect-once-v1";
|
|
28
|
+
|
|
29
|
+
function persist(ctx: ExtensionContext, choice: ProjectChoice) {
|
|
30
|
+
const previous = getProjectChoice();
|
|
31
|
+
setProjectChoice(ctx.sessionManager.getSessionId(), choice);
|
|
32
|
+
if (JSON.stringify(previous) !== JSON.stringify(choice)) pi.appendEntry(PROJECT_CHOICE_ENTRY, choice);
|
|
33
|
+
// Clear the old diagnostic slot on upgrades; the compact work footer owns status.
|
|
34
|
+
ctx.ui.setStatus("devspec-project", undefined);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pick(ctx: ExtensionContext, candidates: ProjectCandidate[], selector?: string): Promise<ProjectCandidate | null> {
|
|
38
|
+
const matches = selector ? matchingProjects(candidates, selector) : candidates;
|
|
39
|
+
if (selector && matches.length === 1) return matches[0]!;
|
|
40
|
+
if (matches.length === 0) throw new Error(selector ? `No accessible project matches “${selector}”. Use an exact name or full project ID.` : "No accessible DevSpec projects are available.");
|
|
41
|
+
if (ctx.mode !== "tui") throw new Error(`Choose a project explicitly with --devspec-project <id>: ${matches.map(project => `${projectLabel(project)} (${project.id})`).join("; ")}`);
|
|
42
|
+
const labels = projectChoiceLabels(matches);
|
|
43
|
+
const choice = await ctx.ui.select("Which DevSpec project should this conversation use?", [...labels, "Continue without connecting"]);
|
|
44
|
+
const index = labels.indexOf(choice ?? "");
|
|
45
|
+
return index < 0 ? null : matches[index]!;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function accessible(ctx: ExtensionContext, selector?: string): Promise<ProjectCandidate | null> {
|
|
49
|
+
const auth = await resolveToken();
|
|
50
|
+
if (!auth) throw new Error("Not connected to DevSpec — /devspec.login first.");
|
|
51
|
+
const raw = await callDevspec(auth.token, "list_projects", {}, { lane: "work" });
|
|
52
|
+
const data = projectResultData(raw);
|
|
53
|
+
if (!Array.isArray(data?.projects)) throw new Error("DevSpec did not return a project list. Please retry.");
|
|
54
|
+
const projects = data.projects.map(readProjectCandidate);
|
|
55
|
+
if (projects.some(p => !p)) throw new Error("DevSpec returned an invalid project choice. Please retry.");
|
|
56
|
+
return pick(ctx, projects as ProjectCandidate[], selector);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function remember(ctx: ExtensionContext, project: ProjectCandidate) {
|
|
60
|
+
if (ctx.mode !== "tui") throw new Error("Remembering a folder default requires an interactive confirmation. Conversation-only selection remains active.");
|
|
61
|
+
const effective = await resolveProjectPinFile(ctx.cwd);
|
|
62
|
+
const target = join(pinTargetDir(ctx.cwd), ".devspec", "project.json");
|
|
63
|
+
if (effective && effective.path !== target) {
|
|
64
|
+
const nested = relative(pinTargetDir(ctx.cwd), dirname(dirname(effective.path)));
|
|
65
|
+
if (nested && nested !== ".." && !nested.startsWith(`..${sep}`)) {
|
|
66
|
+
throw new Error(`A nearer folder default at ${effective.path} would override ${target}. Manage that file explicitly; this conversation's choice is unchanged.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const consent = await ctx.ui.confirm("Remember for this folder?", `${projectLabel(project)}\nWrite ${target}${effective ? `\nCurrent effective default: ${effective.projectId} at ${effective.path}` : ""}\nThis is shared folder configuration and may be committed. It affects future connections, not existing conversations. A unique remote match still wins over a pin.`);
|
|
70
|
+
if (!consent) return;
|
|
71
|
+
// The user confirmed this effective default; don't overwrite a change made while the dialog was open.
|
|
72
|
+
const current = await resolveProjectPinFile(ctx.cwd);
|
|
73
|
+
if (JSON.stringify(current) !== JSON.stringify(effective)) throw new Error("The folder default changed. Review it again before replacing it.");
|
|
74
|
+
const written = await writeProjectPin(ctx.cwd, project.id, project.name);
|
|
75
|
+
ctx.ui.notify(`Folder default saved to ${written.path}. Existing conversations are unchanged.`, "info");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function offerRemember(ctx: ExtensionContext, project: ProjectCandidate) {
|
|
79
|
+
if (ctx.mode !== "tui") return;
|
|
80
|
+
const mode = await ctx.ui.select("How should DevSpec remember this choice?", ["Use for this conversation", "Remember for this folder"]);
|
|
81
|
+
if (mode === "Remember for this folder") await remember(ctx, project);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function connect(ctx: ExtensionContext, room?: string, selector?: string): Promise<ProjectBondOutcome> {
|
|
85
|
+
if (connecting) return { ok: false, error: "A DevSpec connection is already being selected. Finish that choice first.", errorKind: "api" };
|
|
86
|
+
connecting = true;
|
|
87
|
+
try {
|
|
88
|
+
let project: ProjectCandidate | undefined;
|
|
89
|
+
if (selector !== undefined) {
|
|
90
|
+
const selected = await accessible(ctx, selector);
|
|
91
|
+
if (!selected) return { ok: false, cancelled: true, error: "Project selection cancelled; not connected." };
|
|
92
|
+
project = selected;
|
|
93
|
+
const current = getProjectChoice()?.project.id ?? getBond()?.projectId;
|
|
94
|
+
if (current && current !== project.id) return { ok: false, error: "This conversation already uses another project. Use /devspec.project choose to start a fresh conversation.", errorKind: "api" };
|
|
95
|
+
}
|
|
96
|
+
let outcome = await deps.attempt(ctx, room, project);
|
|
97
|
+
if (!outcome.ok && outcome.projectChoice && !project) {
|
|
98
|
+
const selected = await pick(ctx, outcome.projectChoice.candidates);
|
|
99
|
+
if (!selected) return { ok: false, cancelled: true, error: "Project selection cancelled; not connected." };
|
|
100
|
+
outcome = await deps.attempt(ctx, room, selected);
|
|
101
|
+
if (outcome.ok) await offerRemember(ctx, selected);
|
|
102
|
+
}
|
|
103
|
+
return outcome;
|
|
104
|
+
} catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err), errorKind: "api" }; }
|
|
105
|
+
finally { connecting = false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Runs before auto-bond, including in headless mode; never prompts there. */
|
|
109
|
+
async function prepare(ctx: ExtensionContext): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
const endpoint = await resolveEndpoint();
|
|
112
|
+
const entries = ctx.sessionManager.getEntries();
|
|
113
|
+
const intent = entries.filter(entry => entry.type === "custom" && entry.customType === CONNECT_ONCE_ENTRY).at(-1);
|
|
114
|
+
explicitConnect = intent?.type === "custom" && (intent.data as { requested?: unknown } | undefined)?.requested === true;
|
|
115
|
+
if (explicitConnect) pi.appendEntry(CONNECT_ONCE_ENTRY, { requested: false });
|
|
116
|
+
const choice = restoreProjectChoice(entries, ctx.sessionManager.getSessionId(), endpoint);
|
|
117
|
+
if (!choice && entries.some(entry => entry.type === "custom" && entry.customType === PROJECT_CHOICE_ENTRY)) {
|
|
118
|
+
throw new Error("This conversation's saved project cannot be restored for this DevSpec endpoint. Start a fresh local conversation.");
|
|
119
|
+
}
|
|
120
|
+
ctx.ui.setStatus("devspec-project", undefined);
|
|
121
|
+
const selector = consumeProjectLaunchFlag() ? pi.getFlag("devspec-project") : undefined;
|
|
122
|
+
if (selector !== undefined) {
|
|
123
|
+
if (typeof selector !== "string" || !selector.trim()) throw new Error("--devspec-project requires an exact project name or ID.");
|
|
124
|
+
const selected = await accessible(ctx, selector);
|
|
125
|
+
if (!selected) return false;
|
|
126
|
+
if (choice && choice.project.id !== selected.id) throw new Error("This resumed conversation belongs to another project. Start a new Pi conversation without --session/--resume to choose another project.");
|
|
127
|
+
persist(ctx, { version: 1, endpoint, project: selected, source: "explicit" });
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
132
|
+
blockProjectChoice(ctx.sessionManager.getSessionId(), message);
|
|
133
|
+
ctx.ui.notify(message, "error"); return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
pi.registerCommand("devspec.project", {
|
|
138
|
+
description: "Show or choose this conversation's project; remember or forget a folder default. Usage: /devspec.project [status|choose|remember|forget|<name-or-id>]",
|
|
139
|
+
handler: async (args, ctx) => {
|
|
140
|
+
try {
|
|
141
|
+
let command = args.trim();
|
|
142
|
+
const current = getProjectChoice();
|
|
143
|
+
if (!command && ctx.mode === "tui") {
|
|
144
|
+
const actions = [
|
|
145
|
+
["Project details", "status"], ["Choose project", "choose"],
|
|
146
|
+
["Remember for this folder", "remember"], ["Forget folder default", "forget"],
|
|
147
|
+
] as const;
|
|
148
|
+
const picked = await ctx.ui.select(current ? projectLabel(current.project) : "DevSpec project", actions.map(([label]) => label));
|
|
149
|
+
const action = actions.find(([label]) => label === picked);
|
|
150
|
+
if (!action) return;
|
|
151
|
+
command = action[1];
|
|
152
|
+
}
|
|
153
|
+
if (!command || command === "status") {
|
|
154
|
+
const pin = await resolveProjectPinFile(ctx.cwd);
|
|
155
|
+
ctx.ui.notify(`${current ? projectDetails(current) : "No conversation project selected."}\n${getBond() ? "Connected" : "Not connected"}\nFolder default: ${pin ? `${pin.projectId} at ${pin.path}` : "none"}\n/devspec.project choose | remember | forget`, "info");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (command === "remember") {
|
|
159
|
+
if (!current) throw new Error("Choose a project for this conversation first: /devspec.project choose");
|
|
160
|
+
await remember(ctx, current.project); return;
|
|
161
|
+
}
|
|
162
|
+
if (command === "forget") {
|
|
163
|
+
const pin = await resolveProjectPinFile(ctx.cwd);
|
|
164
|
+
if (!pin) { ctx.ui.notify("No usable folder default to forget.", "info"); return; }
|
|
165
|
+
if (ctx.mode !== "tui") throw new Error(`Forgetting requires interactive confirmation of ${pin.path}.`);
|
|
166
|
+
if (!await ctx.ui.confirm("Forget this folder default?", `${pin.projectId}\nRemove ${pin.path}\nThis file may be shared or committed. Existing conversations keep their project.`)) return;
|
|
167
|
+
await forgetProjectPin(pin);
|
|
168
|
+
const next = await resolveProjectPinFile(ctx.cwd);
|
|
169
|
+
ctx.ui.notify(`Folder default removed. Existing conversations are unchanged.${next ? ` Another inherited default remains: ${next.projectId} at ${next.path}` : ""}`, "info"); return;
|
|
170
|
+
}
|
|
171
|
+
if (!ctx.isIdle()) throw new Error("Finish the current turn before choosing another project.");
|
|
172
|
+
const selector = (command.startsWith('"') && command.endsWith('"')) || (command.startsWith("'") && command.endsWith("'")) ? command.slice(1, -1) : command;
|
|
173
|
+
const selected = await accessible(ctx, command === "choose" ? undefined : selector);
|
|
174
|
+
if (!selected) return;
|
|
175
|
+
const currentId = current?.project.id ?? getBond()?.projectId;
|
|
176
|
+
if (currentId && currentId !== selected.id) {
|
|
177
|
+
if (ctx.mode !== "tui") throw new Error(`Start a fresh Pi conversation with --devspec-project ${selected.id}.`);
|
|
178
|
+
if (!await ctx.ui.confirm("Start a fresh conversation?", `${projectLabel(selected)}\nThe previous conversation's model context will not be carried across. Its transcript is kept.`)) return;
|
|
179
|
+
const seed: ProjectChoice = { version: 1, endpoint: await resolveEndpoint(), project: selected, source: "explicit" };
|
|
180
|
+
await deps.stop(ctx);
|
|
181
|
+
// The replacement runtime owns connection setup. Never resume an old
|
|
182
|
+
// extension graph's pump using a new session context.
|
|
183
|
+
const switched = await ctx.newSession({ setup: async manager => {
|
|
184
|
+
manager.appendCustomEntry(PROJECT_CHOICE_ENTRY, seed);
|
|
185
|
+
manager.appendCustomEntry(CONNECT_ONCE_ENTRY, { requested: true });
|
|
186
|
+
} });
|
|
187
|
+
if (switched.cancelled) {
|
|
188
|
+
const restored = await connect(ctx);
|
|
189
|
+
if (!restored.ok) ctx.ui.notify(restored.error ?? "Could not reconnect the previous conversation.", "error");
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!getBond()) {
|
|
194
|
+
const result = await deps.attempt(ctx, undefined, selected);
|
|
195
|
+
if (!result.ok) throw new Error(result.error ?? "Could not connect.");
|
|
196
|
+
}
|
|
197
|
+
await offerRemember(ctx, selected);
|
|
198
|
+
} catch (err) { ctx.ui.notify(err instanceof Error ? err.message : String(err), "error"); }
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
return { prepare, connect, persist, resolve: accessible, consumeExplicitConnect: () => { const requested = explicitConnect; explicitConnect = false; return requested; } };
|
|
202
|
+
}
|