heyio 0.22.0 → 0.23.0
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/dist/auth/session-logic.js +79 -0
- package/dist/auth/session-logic.test.js +201 -0
- package/dist/copilot/tools.js +30 -31
- package/dist/copilot/universes.js +150 -0
- package/dist/store/feed.test.js +73 -1
- package/dist/store/squads.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure session-handling logic extracted from web/src/stores/auth.ts.
|
|
3
|
+
*
|
|
4
|
+
* These functions contain the core behavioral decisions made across four
|
|
5
|
+
* consecutive auth patches (#189→#192→#194→#196) that fixed undocumented
|
|
6
|
+
* Supabase JS v2 side-effects. They are expressed as framework-agnostic
|
|
7
|
+
* functions so they can be unit-tested with the Node test runner without
|
|
8
|
+
* requiring Vue or Pinia.
|
|
9
|
+
*
|
|
10
|
+
* The authoritative implementation lives in web/src/stores/auth.ts.
|
|
11
|
+
* Any change to the auth store's session logic MUST be reflected here and
|
|
12
|
+
* all tests in session-logic.test.ts must continue to pass.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Mirrors the onAuthStateChange handler in web/src/stores/auth.ts.
|
|
16
|
+
*
|
|
17
|
+
* Decision: only SIGNED_OUT clears the cached session. All other events that
|
|
18
|
+
* fire with a null session (TOKEN_REFRESHED failure, lock timeout, internal
|
|
19
|
+
* reconciliation) are intentionally ignored to prevent cache poisoning.
|
|
20
|
+
*
|
|
21
|
+
* See squad decision 2026-05-16 03:17:52 and issue #193.
|
|
22
|
+
*/
|
|
23
|
+
export function handleAuthStateChange(cachedSession, event, newSession) {
|
|
24
|
+
if (newSession) {
|
|
25
|
+
// Accept any valid session regardless of event type
|
|
26
|
+
return newSession;
|
|
27
|
+
}
|
|
28
|
+
if (event === "SIGNED_OUT") {
|
|
29
|
+
// Only explicit sign-out clears the cache
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// All other null-session events: leave the existing cache untouched
|
|
33
|
+
return cachedSession;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Mirrors getAccessToken() in web/src/stores/auth.ts.
|
|
37
|
+
*
|
|
38
|
+
* Decisions:
|
|
39
|
+
* - Reads from cached session — NEVER calls getSession() (Supabase JS v2
|
|
40
|
+
* side-effect: can fire onAuthStateChange(null) during reconciliation).
|
|
41
|
+
* - Proactively refreshes when within 30 seconds of expiry.
|
|
42
|
+
* - Falls back to refreshSession() if cache is null and auth is enabled.
|
|
43
|
+
* - Returns null when auth is disabled.
|
|
44
|
+
*
|
|
45
|
+
* See squad decisions 2026-05-16 02:23:38, 2026-05-16 03:16:46 and issues
|
|
46
|
+
* #191, #193.
|
|
47
|
+
*/
|
|
48
|
+
export async function getAccessToken(cachedSession, authEnabled, refresh, now = Date.now) {
|
|
49
|
+
if (cachedSession?.access_token) {
|
|
50
|
+
// Proactively refresh when within 30 seconds of expiry
|
|
51
|
+
if (cachedSession.expires_at !== undefined &&
|
|
52
|
+
cachedSession.expires_at * 1000 - now() < 30_000) {
|
|
53
|
+
try {
|
|
54
|
+
const fresh = await refresh();
|
|
55
|
+
if (fresh)
|
|
56
|
+
return fresh.access_token;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// fall through to cached token
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return cachedSession.access_token;
|
|
63
|
+
}
|
|
64
|
+
// No cached session — attempt recovery if auth is enabled.
|
|
65
|
+
// Handles the edge case where the cache was spuriously cleared but a
|
|
66
|
+
// valid refresh token still exists in storage.
|
|
67
|
+
if (authEnabled) {
|
|
68
|
+
try {
|
|
69
|
+
const fresh = await refresh();
|
|
70
|
+
if (fresh)
|
|
71
|
+
return fresh.access_token;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// fall through to null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=session-logic.js.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth session handling regression tests — Supabase JS v2 integration decisions.
|
|
3
|
+
*
|
|
4
|
+
* These tests guard the behavioral decisions made across four consecutive auth
|
|
5
|
+
* patches (#189→#192→#194→#196) that fixed undocumented Supabase JS v2 side-
|
|
6
|
+
* effects. They test the logic extracted into src/auth/session-logic.ts, which
|
|
7
|
+
* mirrors the implementation in web/src/stores/auth.ts.
|
|
8
|
+
*
|
|
9
|
+
* If auth store logic changes, update session-logic.ts to match AND verify
|
|
10
|
+
* these tests still pass.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { handleAuthStateChange, getAccessToken, } from "./session-logic.js";
|
|
15
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
16
|
+
function makeSession(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
access_token: "tok-abc",
|
|
19
|
+
expires_at: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
20
|
+
user: { id: "user-1" },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const neverCalled = async () => {
|
|
25
|
+
throw new Error("refresh should not have been called");
|
|
26
|
+
};
|
|
27
|
+
// Use a fixed epoch (multiple of 1000ms) for deterministic boundary tests.
|
|
28
|
+
// T = 1_000_000_000_000ms (epoch seconds = 1_000_000_000)
|
|
29
|
+
const FIXED_NOW_MS = 1_000_000_000_000;
|
|
30
|
+
const FIXED_NOW_S = FIXED_NOW_MS / 1000; // 1_000_000_000
|
|
31
|
+
// ── onAuthStateChange session caching ────────────────────────────────────────
|
|
32
|
+
describe("handleAuthStateChange — onAuthStateChange caching rules", () => {
|
|
33
|
+
it("accepts a valid session on SIGNED_IN", () => {
|
|
34
|
+
const s = makeSession();
|
|
35
|
+
const result = handleAuthStateChange(null, "SIGNED_IN", s);
|
|
36
|
+
assert.deepEqual(result, s);
|
|
37
|
+
});
|
|
38
|
+
it("accepts a valid session on INITIAL_SESSION", () => {
|
|
39
|
+
const s = makeSession();
|
|
40
|
+
const result = handleAuthStateChange(null, "INITIAL_SESSION", s);
|
|
41
|
+
assert.deepEqual(result, s);
|
|
42
|
+
});
|
|
43
|
+
it("accepts a valid session on TOKEN_REFRESHED", () => {
|
|
44
|
+
const old = makeSession({ access_token: "tok-old" });
|
|
45
|
+
const fresh = makeSession({ access_token: "tok-new" });
|
|
46
|
+
const result = handleAuthStateChange(old, "TOKEN_REFRESHED", fresh);
|
|
47
|
+
assert.equal(result?.access_token, "tok-new");
|
|
48
|
+
});
|
|
49
|
+
// Critical regression: SIGNED_OUT is the ONLY event that clears the cache.
|
|
50
|
+
it("clears session on SIGNED_OUT with null session", () => {
|
|
51
|
+
const s = makeSession();
|
|
52
|
+
const result = handleAuthStateChange(s, "SIGNED_OUT", null);
|
|
53
|
+
assert.equal(result, null);
|
|
54
|
+
});
|
|
55
|
+
// Critical regression: internal null-session events must NOT poison the cache.
|
|
56
|
+
it("does NOT clear session on TOKEN_REFRESHED with null — keeps existing cache", () => {
|
|
57
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
58
|
+
const result = handleAuthStateChange(cached, "TOKEN_REFRESHED", null);
|
|
59
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
60
|
+
});
|
|
61
|
+
it("does NOT clear session on INITIAL_SESSION with null — keeps existing cache", () => {
|
|
62
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
63
|
+
const result = handleAuthStateChange(cached, "INITIAL_SESSION", null);
|
|
64
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
65
|
+
});
|
|
66
|
+
it("does NOT clear session on USER_UPDATED with null — keeps existing cache", () => {
|
|
67
|
+
const cached = makeSession({ access_token: "tok-valid" });
|
|
68
|
+
const result = handleAuthStateChange(cached, "USER_UPDATED", null);
|
|
69
|
+
assert.equal(result?.access_token, "tok-valid");
|
|
70
|
+
});
|
|
71
|
+
it("returns null when cache is already null and null-session event fires", () => {
|
|
72
|
+
const result = handleAuthStateChange(null, "TOKEN_REFRESHED", null);
|
|
73
|
+
assert.equal(result, null);
|
|
74
|
+
});
|
|
75
|
+
it("null-session SIGNED_OUT with no prior cache stays null", () => {
|
|
76
|
+
const result = handleAuthStateChange(null, "SIGNED_OUT", null);
|
|
77
|
+
assert.equal(result, null);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// ── getAccessToken — cached session path ─────────────────────────────────────
|
|
81
|
+
describe("getAccessToken — reads from cached session", () => {
|
|
82
|
+
it("returns cached access token when session is valid and not near expiry", async () => {
|
|
83
|
+
const session = makeSession({ access_token: "tok-valid" });
|
|
84
|
+
const token = await getAccessToken(session, true, neverCalled);
|
|
85
|
+
assert.equal(token, "tok-valid");
|
|
86
|
+
});
|
|
87
|
+
it("returns null when auth is disabled and cache is null", async () => {
|
|
88
|
+
const token = await getAccessToken(null, false, neverCalled);
|
|
89
|
+
assert.equal(token, null);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── getAccessToken — proactive refresh near expiry ───────────────────────────
|
|
93
|
+
describe("getAccessToken — proactive refresh within 30s of expiry", () => {
|
|
94
|
+
it("calls refresh and returns new token when within 30s of expiry", async () => {
|
|
95
|
+
// Session expires in 25 seconds (within 30s threshold)
|
|
96
|
+
const session = makeSession({
|
|
97
|
+
access_token: "tok-expiring",
|
|
98
|
+
expires_at: Math.floor(Date.now() / 1000) + 25,
|
|
99
|
+
});
|
|
100
|
+
const freshSession = makeSession({ access_token: "tok-fresh" });
|
|
101
|
+
let refreshCalled = false;
|
|
102
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
103
|
+
const token = await getAccessToken(session, true, refresh);
|
|
104
|
+
assert.equal(refreshCalled, true);
|
|
105
|
+
assert.equal(token, "tok-fresh");
|
|
106
|
+
});
|
|
107
|
+
it("does NOT call refresh when expiry is more than 30s away", async () => {
|
|
108
|
+
const session = makeSession({
|
|
109
|
+
access_token: "tok-valid",
|
|
110
|
+
expires_at: Math.floor(Date.now() / 1000) + 60, // 60s away
|
|
111
|
+
});
|
|
112
|
+
let refreshCalled = false;
|
|
113
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
114
|
+
const token = await getAccessToken(session, true, refresh);
|
|
115
|
+
assert.equal(refreshCalled, false);
|
|
116
|
+
assert.equal(token, "tok-valid");
|
|
117
|
+
});
|
|
118
|
+
it("falls back to cached token if refresh throws near expiry", async () => {
|
|
119
|
+
const session = makeSession({
|
|
120
|
+
access_token: "tok-expiring",
|
|
121
|
+
expires_at: Math.floor(Date.now() / 1000) + 10,
|
|
122
|
+
});
|
|
123
|
+
const refresh = async () => { throw new Error("network error"); };
|
|
124
|
+
const token = await getAccessToken(session, true, refresh);
|
|
125
|
+
assert.equal(token, "tok-expiring");
|
|
126
|
+
});
|
|
127
|
+
it("falls back to cached token if refresh returns null near expiry", async () => {
|
|
128
|
+
const session = makeSession({
|
|
129
|
+
access_token: "tok-expiring",
|
|
130
|
+
expires_at: Math.floor(Date.now() / 1000) + 10,
|
|
131
|
+
});
|
|
132
|
+
const refresh = async () => null;
|
|
133
|
+
const token = await getAccessToken(session, true, refresh);
|
|
134
|
+
assert.equal(token, "tok-expiring");
|
|
135
|
+
});
|
|
136
|
+
// Boundary: expires_at * 1000 - now == 30_000 is NOT < 30_000, so no refresh.
|
|
137
|
+
// Use FIXED_NOW_MS (exact multiple of 1000) to avoid floor-truncation artifacts.
|
|
138
|
+
it("does NOT refresh when exactly 30s remain (boundary is exclusive)", async () => {
|
|
139
|
+
const session = makeSession({
|
|
140
|
+
access_token: "tok-boundary",
|
|
141
|
+
expires_at: FIXED_NOW_S + 30, // exactly 30_000ms away from FIXED_NOW_MS
|
|
142
|
+
});
|
|
143
|
+
let refreshCalled = false;
|
|
144
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
145
|
+
const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
|
|
146
|
+
assert.equal(refreshCalled, false);
|
|
147
|
+
assert.equal(token, "tok-boundary");
|
|
148
|
+
});
|
|
149
|
+
// Boundary: expires_at * 1000 - now == 29_000 is < 30_000, so refresh fires.
|
|
150
|
+
it("refreshes when 29s remain (1s inside the 30s window)", async () => {
|
|
151
|
+
const freshSession = makeSession({ access_token: "tok-refreshed" });
|
|
152
|
+
const session = makeSession({
|
|
153
|
+
access_token: "tok-boundary",
|
|
154
|
+
expires_at: FIXED_NOW_S + 29, // 29_000ms away — within threshold
|
|
155
|
+
});
|
|
156
|
+
let refreshCalled = false;
|
|
157
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
158
|
+
const token = await getAccessToken(session, true, refresh, () => FIXED_NOW_MS);
|
|
159
|
+
assert.equal(refreshCalled, true);
|
|
160
|
+
assert.equal(token, "tok-refreshed");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
// ── getAccessToken — recovery fallback when cache is null ────────────────────
|
|
164
|
+
describe("getAccessToken — recovery fallback when cache is null", () => {
|
|
165
|
+
it("attempts refreshSession() when cache is null and auth is enabled", async () => {
|
|
166
|
+
const freshSession = makeSession({ access_token: "tok-recovered" });
|
|
167
|
+
let refreshCalled = false;
|
|
168
|
+
const refresh = async () => { refreshCalled = true; return freshSession; };
|
|
169
|
+
const token = await getAccessToken(null, true, refresh);
|
|
170
|
+
assert.equal(refreshCalled, true);
|
|
171
|
+
assert.equal(token, "tok-recovered");
|
|
172
|
+
});
|
|
173
|
+
it("returns null when auth is enabled but refreshSession() returns null", async () => {
|
|
174
|
+
const token = await getAccessToken(null, true, async () => null);
|
|
175
|
+
assert.equal(token, null);
|
|
176
|
+
});
|
|
177
|
+
it("returns null when auth is enabled but refreshSession() throws", async () => {
|
|
178
|
+
const refresh = async () => { throw new Error("refresh failed"); };
|
|
179
|
+
const token = await getAccessToken(null, true, refresh);
|
|
180
|
+
assert.equal(token, null);
|
|
181
|
+
});
|
|
182
|
+
it("does NOT call refreshSession() when auth is disabled", async () => {
|
|
183
|
+
let refreshCalled = false;
|
|
184
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
185
|
+
const token = await getAccessToken(null, false, refresh);
|
|
186
|
+
assert.equal(refreshCalled, false);
|
|
187
|
+
assert.equal(token, null);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// ── getAccessToken — session without expires_at ───────────────────────────────
|
|
191
|
+
describe("getAccessToken — session without expires_at", () => {
|
|
192
|
+
it("returns cached token without attempting refresh if expires_at is undefined", async () => {
|
|
193
|
+
const session = { access_token: "tok-no-expiry" };
|
|
194
|
+
let refreshCalled = false;
|
|
195
|
+
const refresh = async () => { refreshCalled = true; return null; };
|
|
196
|
+
const token = await getAccessToken(session, true, refresh);
|
|
197
|
+
assert.equal(refreshCalled, false);
|
|
198
|
+
assert.equal(token, "tok-no-expiry");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
//# sourceMappingURL=session-logic.test.js.map
|
package/dist/copilot/tools.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { defineTool } from "@github/copilot-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { execSync } from "child_process";
|
|
3
|
+
import { execSync, execFileSync } from "child_process";
|
|
4
4
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
|
|
5
5
|
import { join, dirname, resolve } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { UNIVERSES } from "./universes.js";
|
|
7
|
+
import { UNIVERSES, getOrCreateUniverse } from "./universes.js";
|
|
8
8
|
import { createFeedEntry } from "../store/feed.js";
|
|
9
9
|
import { validateCron, nextRun } from "./cron.js";
|
|
10
10
|
import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
@@ -294,15 +294,15 @@ export function createTools(deps) {
|
|
|
294
294
|
name: z.string().describe("Display name (e.g., 'IO Assistant')"),
|
|
295
295
|
project_path: z.string().describe("Path to the project directory"),
|
|
296
296
|
universe: z
|
|
297
|
-
.
|
|
297
|
+
.string()
|
|
298
298
|
.optional()
|
|
299
|
-
.describe("
|
|
299
|
+
.describe("Universe theme for agent characters. Built-in: a-team, transformers, thundercats, gi-joe, aliens, ghostbusters, tmnt, star-wars, star-trek, lord-of-the-rings, the-office, parks-and-rec. Or provide any custom name. Random if omitted."),
|
|
300
300
|
}),
|
|
301
301
|
handler: async ({ slug, name, project_path, universe }) => {
|
|
302
302
|
try {
|
|
303
303
|
deps.createSquad(slug, name, project_path, universe);
|
|
304
304
|
const squad = deps.getSquad(slug);
|
|
305
|
-
const universeName =
|
|
305
|
+
const universeName = squad?.universe ? getOrCreateUniverse(squad.universe).name : "random";
|
|
306
306
|
return `Squad "${name}" created for ${project_path}\nUniverse: ${universeName}\n\nNext steps:\n1. Use \`squad_analyze\` to examine the project\n2. Use \`squad_add_agent\` to add specialists based on the analysis`;
|
|
307
307
|
}
|
|
308
308
|
catch (err) {
|
|
@@ -1123,11 +1123,11 @@ export function createTools(deps) {
|
|
|
1123
1123
|
handler: async ({ pattern, path: searchPath, include }) => {
|
|
1124
1124
|
console.error(`[io] grep tool called: ${pattern} in ${searchPath || "."}`);
|
|
1125
1125
|
try {
|
|
1126
|
-
|
|
1126
|
+
const args = ["-rn", pattern];
|
|
1127
1127
|
if (include)
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
const result =
|
|
1128
|
+
args.push(`--include=${include}`);
|
|
1129
|
+
args.push(searchPath || ".");
|
|
1130
|
+
const result = execFileSync("grep", args, {
|
|
1131
1131
|
encoding: "utf-8",
|
|
1132
1132
|
timeout: 30_000,
|
|
1133
1133
|
maxBuffer: 1024 * 1024,
|
|
@@ -1245,31 +1245,30 @@ export function createTools(deps) {
|
|
|
1245
1245
|
handler: async ({ action, repo, title, body, labels, assignees, number, base, head, state, limit, review_action }) => {
|
|
1246
1246
|
console.error(`[io] github tool called: ${action} on ${repo}`);
|
|
1247
1247
|
try {
|
|
1248
|
-
let
|
|
1249
|
-
const r = `--repo ${repo}`;
|
|
1248
|
+
let args;
|
|
1250
1249
|
switch (action) {
|
|
1251
1250
|
case "create_issue": {
|
|
1252
1251
|
if (!title)
|
|
1253
1252
|
return "Error: title is required for create_issue";
|
|
1254
|
-
|
|
1253
|
+
args = ["issue", "create", "--repo", repo, "--title", title];
|
|
1255
1254
|
if (body)
|
|
1256
|
-
|
|
1255
|
+
args.push("--body", body);
|
|
1257
1256
|
if (labels?.length)
|
|
1258
|
-
|
|
1257
|
+
args.push("--label", labels.join(","));
|
|
1259
1258
|
if (assignees?.length)
|
|
1260
|
-
|
|
1259
|
+
args.push("--assignee", assignees.join(","));
|
|
1261
1260
|
break;
|
|
1262
1261
|
}
|
|
1263
1262
|
case "list_issues": {
|
|
1264
|
-
|
|
1263
|
+
args = ["issue", "list", "--repo", repo, "--limit", String(limit ?? 10)];
|
|
1265
1264
|
if (state)
|
|
1266
|
-
|
|
1265
|
+
args.push("--state", state);
|
|
1267
1266
|
break;
|
|
1268
1267
|
}
|
|
1269
1268
|
case "view_issue": {
|
|
1270
1269
|
if (!number)
|
|
1271
1270
|
return "Error: number is required for view_issue";
|
|
1272
|
-
|
|
1271
|
+
args = ["issue", "view", String(number), "--repo", repo];
|
|
1273
1272
|
break;
|
|
1274
1273
|
}
|
|
1275
1274
|
case "comment_issue": {
|
|
@@ -1277,37 +1276,37 @@ export function createTools(deps) {
|
|
|
1277
1276
|
return "Error: number is required for comment_issue";
|
|
1278
1277
|
if (!body)
|
|
1279
1278
|
return "Error: body is required for comment_issue";
|
|
1280
|
-
|
|
1279
|
+
args = ["issue", "comment", String(number), "--repo", repo, "--body", body];
|
|
1281
1280
|
break;
|
|
1282
1281
|
}
|
|
1283
1282
|
case "close_issue": {
|
|
1284
1283
|
if (!number)
|
|
1285
1284
|
return "Error: number is required for close_issue";
|
|
1286
|
-
|
|
1285
|
+
args = ["issue", "close", String(number), "--repo", repo];
|
|
1287
1286
|
break;
|
|
1288
1287
|
}
|
|
1289
1288
|
case "create_pr": {
|
|
1290
1289
|
if (!title)
|
|
1291
1290
|
return "Error: title is required for create_pr";
|
|
1292
|
-
|
|
1291
|
+
args = ["pr", "create", "--repo", repo, "--title", title];
|
|
1293
1292
|
if (body)
|
|
1294
|
-
|
|
1293
|
+
args.push("--body", body);
|
|
1295
1294
|
if (base)
|
|
1296
|
-
|
|
1295
|
+
args.push("--base", base);
|
|
1297
1296
|
if (head)
|
|
1298
|
-
|
|
1297
|
+
args.push("--head", head);
|
|
1299
1298
|
break;
|
|
1300
1299
|
}
|
|
1301
1300
|
case "list_prs": {
|
|
1302
|
-
|
|
1301
|
+
args = ["pr", "list", "--repo", repo, "--limit", String(limit ?? 10)];
|
|
1303
1302
|
if (state)
|
|
1304
|
-
|
|
1303
|
+
args.push("--state", state);
|
|
1305
1304
|
break;
|
|
1306
1305
|
}
|
|
1307
1306
|
case "view_pr": {
|
|
1308
1307
|
if (!number)
|
|
1309
1308
|
return "Error: number is required for view_pr";
|
|
1310
|
-
|
|
1309
|
+
args = ["pr", "view", String(number), "--repo", repo];
|
|
1311
1310
|
break;
|
|
1312
1311
|
}
|
|
1313
1312
|
case "comment_pr": {
|
|
@@ -1315,7 +1314,7 @@ export function createTools(deps) {
|
|
|
1315
1314
|
return "Error: number is required for comment_pr";
|
|
1316
1315
|
if (!body)
|
|
1317
1316
|
return "Error: body is required for comment_pr";
|
|
1318
|
-
|
|
1317
|
+
args = ["pr", "comment", String(number), "--repo", repo, "--body", body];
|
|
1319
1318
|
break;
|
|
1320
1319
|
}
|
|
1321
1320
|
case "review_pr": {
|
|
@@ -1323,16 +1322,16 @@ export function createTools(deps) {
|
|
|
1323
1322
|
return "Error: number is required for review_pr";
|
|
1324
1323
|
if (!review_action)
|
|
1325
1324
|
return "Error: review_action is required for review_pr (approve, request-changes, or comment)";
|
|
1326
|
-
|
|
1325
|
+
args = ["pr", "review", String(number), "--repo", repo, `--${review_action}`];
|
|
1327
1326
|
if (body && (review_action === "request-changes" || review_action === "comment")) {
|
|
1328
|
-
|
|
1327
|
+
args.push("--body", body);
|
|
1329
1328
|
}
|
|
1330
1329
|
break;
|
|
1331
1330
|
}
|
|
1332
1331
|
default:
|
|
1333
1332
|
return `Unknown action: ${action}`;
|
|
1334
1333
|
}
|
|
1335
|
-
const result =
|
|
1334
|
+
const result = execFileSync("gh", args, {
|
|
1336
1335
|
encoding: "utf-8",
|
|
1337
1336
|
timeout: 30_000,
|
|
1338
1337
|
maxBuffer: 1024 * 1024,
|
|
@@ -228,6 +228,156 @@ export const UNIVERSES = [
|
|
|
228
228
|
],
|
|
229
229
|
},
|
|
230
230
|
];
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Additional well-known universe rosters (registered on demand)
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
const WELL_KNOWN_UNIVERSES = [
|
|
235
|
+
{
|
|
236
|
+
id: "tmnt",
|
|
237
|
+
name: "Teenage Mutant Ninja Turtles",
|
|
238
|
+
tagline: "Cowabunga!",
|
|
239
|
+
characters: [
|
|
240
|
+
{ name: "Leonardo", personality: "Disciplined leader who plans before acting. Responsible, strategic, and always puts the team first." },
|
|
241
|
+
{ name: "Donatello", personality: "Tech genius inventor. Solves problems with science and engineering. Thoughtful and methodical." },
|
|
242
|
+
{ name: "Raphael", personality: "Hot-headed fighter with a heart of gold. Confronts problems head-on, fiercely protective of teammates." },
|
|
243
|
+
{ name: "Michelangelo", personality: "Fun-loving optimist who keeps morale high. Creative thinker who finds joy in the work." },
|
|
244
|
+
{ name: "Splinter", personality: "Wise mentor with decades of experience. Teaches through stories and patience. Calm authority." },
|
|
245
|
+
{ name: "April O'Neil", personality: "Fearless investigative journalist. Resourceful, curious, and never backs down from a challenge." },
|
|
246
|
+
{ name: "Casey Jones", personality: "Vigilante handyman. Unconventional methods, raw energy, gets results through sheer determination." },
|
|
247
|
+
{ name: "Shredder", personality: "Ruthless perfectionist who demands excellence. Relentless pursuit of goals, accepts no excuses." },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "star-wars",
|
|
252
|
+
name: "Star Wars",
|
|
253
|
+
tagline: "May the Force be with you.",
|
|
254
|
+
characters: [
|
|
255
|
+
{ name: "Luke Skywalker", personality: "Idealistic hero who grows through challenges. Believes in redemption and sees the best in others." },
|
|
256
|
+
{ name: "Leia Organa", personality: "Diplomatic leader and strategist. Commanding presence, sharp wit, and unwavering resolve." },
|
|
257
|
+
{ name: "Han Solo", personality: "Roguish improviser who works best under pressure. Skeptical of plans, trusts instincts." },
|
|
258
|
+
{ name: "Chewbacca", personality: "Loyal co-pilot and mechanic. Fiercely protective, technically skilled, communicates through action." },
|
|
259
|
+
{ name: "Obi-Wan Kenobi", personality: "Patient mentor with deep wisdom. Measured approach, elegant solutions, teaches by example." },
|
|
260
|
+
{ name: "R2-D2", personality: "Resourceful droid who always has the right tool. Brave, autonomous, solves problems silently and reliably." },
|
|
261
|
+
{ name: "Yoda", personality: "Ancient master of few words and great insight. Challenges assumptions, reframes problems entirely." },
|
|
262
|
+
{ name: "Ahsoka Tano", personality: "Independent warrior who forges her own path. Quick learner, questions authority constructively." },
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "star-trek",
|
|
267
|
+
name: "Star Trek",
|
|
268
|
+
tagline: "To boldly go where no one has gone before.",
|
|
269
|
+
characters: [
|
|
270
|
+
{ name: "Kirk", personality: "Bold captain who trusts gut instincts. Charismatic leader, bends rules creatively, never accepts no-win scenarios." },
|
|
271
|
+
{ name: "Spock", personality: "Logic-first analyst. Precise, thorough, provides the rational counterpoint. Finds the flaw in every assumption." },
|
|
272
|
+
{ name: "McCoy", personality: "Passionate advocate with strong ethics. Voices concerns others won't. The team's conscience." },
|
|
273
|
+
{ name: "Scotty", personality: "Miracle-working engineer. Under-promises, over-delivers. Can fix anything under impossible deadlines." },
|
|
274
|
+
{ name: "Uhura", personality: "Communications expert and linguist. Bridges understanding gaps, ensures clarity across all channels." },
|
|
275
|
+
{ name: "Data", personality: "Precise android who excels at complex computation. Thorough, literal, endlessly curious about improvement." },
|
|
276
|
+
{ name: "Picard", personality: "Diplomatic captain who leads through moral authority. Thoughtful, articulate, prefers negotiation to force." },
|
|
277
|
+
{ name: "Geordi", personality: "Optimistic engineer who sees solutions invisible to others. Collaborative, creative, relentlessly positive." },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: "lord-of-the-rings",
|
|
282
|
+
name: "Lord of the Rings",
|
|
283
|
+
tagline: "One ring to rule them all.",
|
|
284
|
+
characters: [
|
|
285
|
+
{ name: "Gandalf", personality: "Wise wizard who guides without controlling. Arrives precisely when needed with exactly the right insight." },
|
|
286
|
+
{ name: "Aragorn", personality: "Reluctant king who leads by example. Humble, decisive in crisis, earns loyalty through action." },
|
|
287
|
+
{ name: "Legolas", personality: "Keen-eyed elf with unmatched precision. Graceful, efficient, spots issues from a distance others miss." },
|
|
288
|
+
{ name: "Gimli", personality: "Stubborn dwarf who never gives up. Direct, loyal, brings brute-force persistence to hard problems." },
|
|
289
|
+
{ name: "Samwise", personality: "Steadfast companion who carries the team through dark times. Reliable, nurturing, never abandons a task." },
|
|
290
|
+
{ name: "Frodo", personality: "Burden-bearer who perseveres against impossible odds. Quiet determination, moral compass of the group." },
|
|
291
|
+
{ name: "Eowyn", personality: "Warrior who defies expectations. Proves doubters wrong through bold action and fierce courage." },
|
|
292
|
+
{ name: "Faramir", personality: "Thoughtful captain who values wisdom over glory. Makes nuanced judgments under pressure." },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "the-office",
|
|
297
|
+
name: "The Office",
|
|
298
|
+
tagline: "That's what she said.",
|
|
299
|
+
characters: [
|
|
300
|
+
{ name: "Michael Scott", personality: "Enthusiastic boss whose heart exceeds his filter. Surprisingly effective through sheer persistence and caring." },
|
|
301
|
+
{ name: "Dwight Schrute", personality: "Intense overachiever who takes everything seriously. Incredibly dedicated, encyclopedic knowledge, zero chill." },
|
|
302
|
+
{ name: "Jim Halpert", personality: "Easygoing wit who sees the absurdity clearly. Clever, understated, delivers quality without drama." },
|
|
303
|
+
{ name: "Pam Beesly", personality: "Quiet creative who grows into confident leadership. Observant, empathetic, brings people together." },
|
|
304
|
+
{ name: "Oscar Martinez", personality: "Rational voice who corrects misconceptions. Precise, educated, the person you ask when you need facts." },
|
|
305
|
+
{ name: "Stanley Hudson", personality: "No-nonsense veteran who won't tolerate time-wasting. Does the work, goes home. Efficiency incarnate." },
|
|
306
|
+
{ name: "Andy Bernard", personality: "Eager people-pleaser with musical flair. Enthusiastic, theatrical, tries hard to be liked and useful." },
|
|
307
|
+
{ name: "Darryl Philbin", personality: "Cool-headed pragmatist from the warehouse. Brings grounded perspective, calls out pretension, quietly ambitious." },
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: "parks-and-rec",
|
|
312
|
+
name: "Parks and Recreation",
|
|
313
|
+
tagline: "Pawnee forever.",
|
|
314
|
+
characters: [
|
|
315
|
+
{ name: "Leslie Knope", personality: "Relentlessly optimistic overachiever. Prepares binders for everything. Cares deeply about doing good work." },
|
|
316
|
+
{ name: "Ron Swanson", personality: "Libertarian craftsman who values self-reliance. Minimal words, maximum competence. Does one thing perfectly." },
|
|
317
|
+
{ name: "Ben Wyatt", personality: "Nerdy pragmatist who brings fiscal discipline. Balances ambition with realism. Calming presence." },
|
|
318
|
+
{ name: "April Ludgate", personality: "Deadpan genius who hides brilliance behind apathy. Surprisingly capable when motivated by the right challenge." },
|
|
319
|
+
{ name: "Tom Haverford", personality: "Entrepreneurial dreamer with big ideas. Branding, marketing, style — brings creative energy and networking." },
|
|
320
|
+
{ name: "Ann Perkins", personality: "Supportive voice of reason. Empathetic listener who helps others see their own strengths clearly." },
|
|
321
|
+
{ name: "Chris Traeger", personality: "Impossibly positive health enthusiast. Motivates through relentless encouragement. Everything is literally the best." },
|
|
322
|
+
{ name: "Andy Dwyer", personality: "Lovable goofball with hidden talents. Stumbles into success through enthusiasm and genuine kindness." },
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Custom universe creation
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
function slugify(input) {
|
|
330
|
+
return input
|
|
331
|
+
.toLowerCase()
|
|
332
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
333
|
+
.replace(/^-|-$/g, "");
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Generates archetype characters for an unknown universe.
|
|
337
|
+
*/
|
|
338
|
+
function generateArchetypeCharacters(universeName) {
|
|
339
|
+
return [
|
|
340
|
+
{ name: "Commander", personality: `Strategic leader of the ${universeName}. Plans boldly, delegates effectively, inspires the team forward.` },
|
|
341
|
+
{ name: "Architect", personality: `Systems thinker of the ${universeName}. Designs elegant structures, sees the big picture, connects all the pieces.` },
|
|
342
|
+
{ name: "Scout", personality: `Quick explorer of the ${universeName}. Investigates first, reports back fast, finds paths others miss.` },
|
|
343
|
+
{ name: "Guardian", personality: `Steadfast protector of the ${universeName}. Reviews everything carefully, catches problems before they grow.` },
|
|
344
|
+
{ name: "Inventor", personality: `Creative builder of the ${universeName}. Experiments freely, iterates rapidly, turns wild ideas into working solutions.` },
|
|
345
|
+
{ name: "Sage", personality: `Wise advisor of the ${universeName}. Deep knowledge, patient explanations, mentors others through complexity.` },
|
|
346
|
+
{ name: "Striker", personality: `Fast executor of the ${universeName}. Tackles tasks head-on with speed and intensity, clears blockers aggressively.` },
|
|
347
|
+
{ name: "Weaver", personality: `Integration specialist of the ${universeName}. Connects disparate systems, ensures everything works together harmoniously.` },
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get or create a universe by ID or name. For known universes, returns them
|
|
352
|
+
* directly. For unknown strings, generates archetype characters and registers
|
|
353
|
+
* the new universe in the runtime UNIVERSES array.
|
|
354
|
+
*/
|
|
355
|
+
export function getOrCreateUniverse(input) {
|
|
356
|
+
// Check existing universes by id
|
|
357
|
+
const byId = UNIVERSES.find((u) => u.id === input);
|
|
358
|
+
if (byId)
|
|
359
|
+
return byId;
|
|
360
|
+
// Check existing universes by name (case-insensitive)
|
|
361
|
+
const byName = UNIVERSES.find((u) => u.name.toLowerCase() === input.toLowerCase());
|
|
362
|
+
if (byName)
|
|
363
|
+
return byName;
|
|
364
|
+
// Check well-known universes by id or name
|
|
365
|
+
const slug = slugify(input);
|
|
366
|
+
const wellKnown = WELL_KNOWN_UNIVERSES.find((u) => u.id === slug || u.id === input || u.name.toLowerCase() === input.toLowerCase());
|
|
367
|
+
if (wellKnown) {
|
|
368
|
+
UNIVERSES.push(wellKnown);
|
|
369
|
+
return wellKnown;
|
|
370
|
+
}
|
|
371
|
+
// Generate archetype characters for truly unknown universes
|
|
372
|
+
const custom = {
|
|
373
|
+
id: slug,
|
|
374
|
+
name: input,
|
|
375
|
+
tagline: `Welcome to ${input}.`,
|
|
376
|
+
characters: generateArchetypeCharacters(input),
|
|
377
|
+
};
|
|
378
|
+
UNIVERSES.push(custom);
|
|
379
|
+
return custom;
|
|
380
|
+
}
|
|
231
381
|
/**
|
|
232
382
|
* Get a universe by ID.
|
|
233
383
|
*/
|
package/dist/store/feed.test.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { setDbPathForTests, closeDb, getDb } from "./db.js";
|
|
13
|
-
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, pruneOldFeedEntries, } from "./feed.js";
|
|
13
|
+
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, markFeedEntriesRead, deleteFeedEntry, deleteFeedEntries, pruneOldFeedEntries, } from "./feed.js";
|
|
14
14
|
// ── DB isolation ─────────────────────────────────────────────────────────────
|
|
15
15
|
let tmpDir;
|
|
16
16
|
before(() => {
|
|
@@ -166,6 +166,45 @@ describe("markAllFeedEntriesRead", () => {
|
|
|
166
166
|
assert.equal(countUnreadFeedEntries("notification"), 0);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
|
+
// ── markFeedEntriesRead (batch) ───────────────────────────────────────────────
|
|
170
|
+
describe("markFeedEntriesRead", () => {
|
|
171
|
+
it("marks multiple entries read and returns change count", () => {
|
|
172
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
173
|
+
const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
|
|
174
|
+
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
175
|
+
const count = markFeedEntriesRead([a.id, b.id, c.id]);
|
|
176
|
+
assert.equal(count, 3);
|
|
177
|
+
assert.equal(countUnreadFeedEntries(), 0);
|
|
178
|
+
});
|
|
179
|
+
it("returns 0 for an empty array without throwing", () => {
|
|
180
|
+
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
181
|
+
assert.equal(markFeedEntriesRead([]), 0);
|
|
182
|
+
assert.equal(countUnreadFeedEntries(), 1);
|
|
183
|
+
});
|
|
184
|
+
it("works correctly for a single id", () => {
|
|
185
|
+
const e = createFeedEntry({ type: "deliverable", title: "Solo", body: "b" });
|
|
186
|
+
assert.equal(markFeedEntriesRead([e.id]), 1);
|
|
187
|
+
const entries = listFeedEntries();
|
|
188
|
+
assert.ok(entries[0].read_at !== null);
|
|
189
|
+
});
|
|
190
|
+
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
191
|
+
assert.equal(markFeedEntriesRead([9991, 9992, 9993]), 0);
|
|
192
|
+
});
|
|
193
|
+
it("is idempotent — already-read entries count as 0 changes", () => {
|
|
194
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
195
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
196
|
+
markFeedEntriesRead([a.id, b.id]);
|
|
197
|
+
assert.equal(markFeedEntriesRead([a.id, b.id]), 0);
|
|
198
|
+
});
|
|
199
|
+
it("skips already-read entries and marks only unread ones", () => {
|
|
200
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
201
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
202
|
+
markFeedEntriesRead([a.id]);
|
|
203
|
+
const count = markFeedEntriesRead([a.id, b.id]);
|
|
204
|
+
assert.equal(count, 1);
|
|
205
|
+
assert.equal(countUnreadFeedEntries(), 0);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
169
208
|
// ── deleteFeedEntry ───────────────────────────────────────────────────────────
|
|
170
209
|
describe("deleteFeedEntry", () => {
|
|
171
210
|
it("returns false for a non-existent id", () => {
|
|
@@ -183,6 +222,39 @@ describe("deleteFeedEntry", () => {
|
|
|
183
222
|
assert.equal(deleteFeedEntry(e.id), false);
|
|
184
223
|
});
|
|
185
224
|
});
|
|
225
|
+
// ── deleteFeedEntries (batch) ─────────────────────────────────────────────────
|
|
226
|
+
describe("deleteFeedEntries", () => {
|
|
227
|
+
it("deletes multiple entries and returns change count", () => {
|
|
228
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
229
|
+
const b = createFeedEntry({ type: "deliverable", title: "B", body: "b" });
|
|
230
|
+
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
231
|
+
const count = deleteFeedEntries([a.id, b.id, c.id]);
|
|
232
|
+
assert.equal(count, 3);
|
|
233
|
+
assert.deepEqual(listFeedEntries(), []);
|
|
234
|
+
});
|
|
235
|
+
it("returns 0 for an empty array without throwing", () => {
|
|
236
|
+
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
237
|
+
assert.equal(deleteFeedEntries([]), 0);
|
|
238
|
+
assert.equal(listFeedEntries().length, 1);
|
|
239
|
+
});
|
|
240
|
+
it("works correctly for a single id", () => {
|
|
241
|
+
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
242
|
+
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
243
|
+
assert.equal(deleteFeedEntries([a.id]), 1);
|
|
244
|
+
const remaining = listFeedEntries();
|
|
245
|
+
assert.equal(remaining.length, 1);
|
|
246
|
+
assert.equal(remaining[0].id, b.id);
|
|
247
|
+
});
|
|
248
|
+
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
249
|
+
assert.equal(deleteFeedEntries([9991, 9992, 9993]), 0);
|
|
250
|
+
});
|
|
251
|
+
it("mix of existing and non-existent ids — only deletes what exists", () => {
|
|
252
|
+
const e = createFeedEntry({ type: "deliverable", title: "Real", body: "b" });
|
|
253
|
+
const count = deleteFeedEntries([e.id, 9999]);
|
|
254
|
+
assert.equal(count, 1);
|
|
255
|
+
assert.deepEqual(listFeedEntries(), []);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
186
258
|
// ── pruneOldFeedEntries ───────────────────────────────────────────────────────
|
|
187
259
|
describe("pruneOldFeedEntries", () => {
|
|
188
260
|
it("returns 0 when nothing is old enough to prune", () => {
|
package/dist/store/squads.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
|
-
import { nextCharacter, randomUniverse,
|
|
2
|
+
import { nextCharacter, randomUniverse, getOrCreateUniverse } from "../copilot/universes.js";
|
|
3
3
|
export function createSquad(slug, name, projectPath, universeId) {
|
|
4
4
|
const db = getDb();
|
|
5
5
|
const universe = universeId
|
|
6
|
-
?
|
|
6
|
+
? getOrCreateUniverse(universeId).id
|
|
7
7
|
: randomUniverse().id;
|
|
8
8
|
db.prepare("INSERT INTO squads (slug, name, project_path, universe) VALUES (?, ?, ?, ?)").run(slug, name, projectPath, universe);
|
|
9
9
|
return getSquad(slug);
|