runcastle 1.2.6 → 1.2.7
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/bin/runcastle.js +316 -85
- package/drizzle/0026_polite_vertigo.sql +2 -0
- package/drizzle/meta/0026_snapshot.json +921 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +312 -81
- package/package.json +1 -1
- package/skills/burner/review-ticket.md +118 -36
- package/skills/packs/README.md +6 -4
- package/skills/packs/runcastle/.claude-plugin/plugin.json +1 -1
- package/skills/packs/runcastle/skills/code-review/SKILL.md +131 -0
- package/skills/packs/runcastle/skills/project/SKILL.md +38 -13
- package/skills/packs/runcastle/skills/tickets/SKILL.md +6 -1
- package/web/assets/index-B3xwZ1FR.css +1 -0
- package/web/assets/index-C0S4i8wY.js +142 -0
- package/web/index.html +2 -2
- package/web/assets/index-bof4-dhY.css +0 -1
- package/web/assets/index-qHtxEVWq.js +0 -142
package/index.js
CHANGED
|
@@ -408,7 +408,9 @@ var init_schemas = __esm(() => {
|
|
|
408
408
|
transcriptPath: z2.string().optional(),
|
|
409
409
|
status: SessionStatus,
|
|
410
410
|
awaitingInput: z2.boolean(),
|
|
411
|
-
worktreePath: z2.string()
|
|
411
|
+
worktreePath: z2.string(),
|
|
412
|
+
title: z2.string().optional(),
|
|
413
|
+
createdAt: z2.number().optional()
|
|
412
414
|
});
|
|
413
415
|
Run = z2.object({
|
|
414
416
|
id: z2.string(),
|
|
@@ -649,7 +651,9 @@ var init_db_schema = __esm(() => {
|
|
|
649
651
|
transcriptPath: text("transcript_path"),
|
|
650
652
|
status: text("status").notNull().$type(),
|
|
651
653
|
awaitingInput: integer("awaiting_input", { mode: "boolean" }).notNull().default(false),
|
|
652
|
-
worktreePath: text("worktree_path").notNull()
|
|
654
|
+
worktreePath: text("worktree_path").notNull(),
|
|
655
|
+
title: text("title"),
|
|
656
|
+
createdAt: integer("created_at")
|
|
653
657
|
});
|
|
654
658
|
tickets = sqliteTable("tickets", {
|
|
655
659
|
id: text("id").primaryKey(),
|
|
@@ -947,24 +951,49 @@ var init_resolve_executable = __esm(() => {
|
|
|
947
951
|
});
|
|
948
952
|
|
|
949
953
|
// src/pty/kill-tree.ts
|
|
950
|
-
import {
|
|
951
|
-
|
|
954
|
+
import { spawn } from "child_process";
|
|
955
|
+
function taskkillTree(pid) {
|
|
956
|
+
return new Promise((resolve3) => {
|
|
957
|
+
let settled = false;
|
|
958
|
+
const settle = () => {
|
|
959
|
+
if (settled)
|
|
960
|
+
return;
|
|
961
|
+
settled = true;
|
|
962
|
+
clearTimeout(timer);
|
|
963
|
+
resolve3();
|
|
964
|
+
};
|
|
965
|
+
const timer = setTimeout(settle, TASKKILL_TIMEOUT_MS);
|
|
966
|
+
timer.unref?.();
|
|
967
|
+
try {
|
|
968
|
+
const child = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
969
|
+
windowsHide: true,
|
|
970
|
+
stdio: "ignore"
|
|
971
|
+
});
|
|
972
|
+
child.on("exit", settle);
|
|
973
|
+
child.on("close", settle);
|
|
974
|
+
child.on("error", settle);
|
|
975
|
+
} catch {
|
|
976
|
+
settle();
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
}
|
|
952
980
|
async function killProcessTree(pid) {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
981
|
+
const started = Date.now();
|
|
982
|
+
const isWin32 = process.platform === "win32";
|
|
983
|
+
if (isWin32) {
|
|
984
|
+
await taskkillTree(pid);
|
|
985
|
+
} else {
|
|
986
|
+
try {
|
|
957
987
|
process.kill(-pid, "SIGTERM");
|
|
958
|
-
}
|
|
959
|
-
}
|
|
988
|
+
} catch {}
|
|
989
|
+
}
|
|
990
|
+
console.error(`[pty-teardown] killProcessTree: pid=${pid} branch=${isWin32 ? "taskkill" : "pgroup"} settled after ${Date.now() - started}ms`);
|
|
960
991
|
}
|
|
961
|
-
var
|
|
962
|
-
var init_kill_tree =
|
|
963
|
-
execFileAsync = promisify(execFile);
|
|
964
|
-
});
|
|
992
|
+
var TASKKILL_TIMEOUT_MS = 3000;
|
|
993
|
+
var init_kill_tree = () => {};
|
|
965
994
|
|
|
966
995
|
// src/pty/pty-sidecar.ts
|
|
967
|
-
import { spawn } from "child_process";
|
|
996
|
+
import { spawn as spawn2 } from "child_process";
|
|
968
997
|
import { createRequire } from "module";
|
|
969
998
|
import { existsSync as existsSync4 } from "fs";
|
|
970
999
|
import { fileURLToPath } from "url";
|
|
@@ -990,7 +1019,7 @@ function createSidecarPtySession(cmd, args, opts) {
|
|
|
990
1019
|
const nodeExe = resolveNodeExecutable();
|
|
991
1020
|
const nodePtyEntry = resolveNodePtyEntry();
|
|
992
1021
|
const hostArgs = nodePtyEntry ? [HOST_PATH, nodePtyEntry] : [HOST_PATH];
|
|
993
|
-
const child =
|
|
1022
|
+
const child = spawn2(nodeExe, hostArgs, {
|
|
994
1023
|
stdio: ["pipe", "pipe", "pipe"],
|
|
995
1024
|
windowsHide: true
|
|
996
1025
|
});
|
|
@@ -1299,15 +1328,36 @@ var init_ring_buffer = __esm(() => {
|
|
|
1299
1328
|
});
|
|
1300
1329
|
|
|
1301
1330
|
// src/pty/registry.ts
|
|
1302
|
-
function
|
|
1331
|
+
function log(msg) {
|
|
1332
|
+
console.error(`[pty-teardown] ${msg}`);
|
|
1333
|
+
}
|
|
1334
|
+
function withDeadline(body, ms) {
|
|
1303
1335
|
return new Promise((resolve3) => {
|
|
1304
|
-
const timer = setTimeout(resolve3,
|
|
1305
|
-
|
|
1336
|
+
const timer = setTimeout(() => resolve3(false), ms);
|
|
1337
|
+
body().catch(() => {}).then(() => {
|
|
1306
1338
|
clearTimeout(timer);
|
|
1307
|
-
resolve3();
|
|
1339
|
+
resolve3(true);
|
|
1308
1340
|
});
|
|
1309
1341
|
});
|
|
1310
1342
|
}
|
|
1343
|
+
async function tearDownEntry(entry) {
|
|
1344
|
+
const id = entry.sessionId;
|
|
1345
|
+
if (entry.exited) {
|
|
1346
|
+
log(`${id}: skip (exited=true)`);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const ptyPid = entry.pty.pid;
|
|
1350
|
+
const started = Date.now();
|
|
1351
|
+
log(`${id}: start exited=false ptyPid=${ptyPid} t=${started}`);
|
|
1352
|
+
const finished = await withDeadline(async () => {
|
|
1353
|
+
await entry.pty.killTree();
|
|
1354
|
+
log(`${id}: killTree settled after ${Date.now() - started}ms`);
|
|
1355
|
+
try {
|
|
1356
|
+
entry.pty.kill();
|
|
1357
|
+
} catch {}
|
|
1358
|
+
}, TEARDOWN_TIMEOUT_MS);
|
|
1359
|
+
log(`${id}: done after ${Date.now() - started}ms deadline=${finished ? "no" : "FIRED"}`);
|
|
1360
|
+
}
|
|
1311
1361
|
|
|
1312
1362
|
class PtyRegistry {
|
|
1313
1363
|
entries = new Map;
|
|
@@ -1364,16 +1414,20 @@ class PtyRegistry {
|
|
|
1364
1414
|
}
|
|
1365
1415
|
kill(sessionId) {
|
|
1366
1416
|
const entry = this.entries.get(sessionId);
|
|
1367
|
-
if (!entry)
|
|
1417
|
+
if (!entry) {
|
|
1418
|
+
log(`${sessionId}: registry miss (kill)`);
|
|
1368
1419
|
return false;
|
|
1369
|
-
|
|
1420
|
+
}
|
|
1421
|
+
tearDownEntry(entry);
|
|
1370
1422
|
return true;
|
|
1371
1423
|
}
|
|
1372
1424
|
async killTree(sessionId) {
|
|
1373
1425
|
const entry = this.entries.get(sessionId);
|
|
1374
|
-
if (!entry)
|
|
1426
|
+
if (!entry) {
|
|
1427
|
+
log(`${sessionId}: registry miss (killTree)`);
|
|
1375
1428
|
return false;
|
|
1376
|
-
|
|
1429
|
+
}
|
|
1430
|
+
await tearDownEntry(entry);
|
|
1377
1431
|
return true;
|
|
1378
1432
|
}
|
|
1379
1433
|
remove(sessionId) {
|
|
@@ -1382,15 +1436,7 @@ class PtyRegistry {
|
|
|
1382
1436
|
async killAllTrees() {
|
|
1383
1437
|
const all = [...this.entries.values()];
|
|
1384
1438
|
this.entries.clear();
|
|
1385
|
-
await Promise.allSettled(all.map((entry) =>
|
|
1386
|
-
}
|
|
1387
|
-
async tearDown(entry) {
|
|
1388
|
-
if (entry.exited)
|
|
1389
|
-
return;
|
|
1390
|
-
await killTreeBounded(entry.pty);
|
|
1391
|
-
try {
|
|
1392
|
-
entry.pty.kill();
|
|
1393
|
-
} catch {}
|
|
1439
|
+
await Promise.allSettled(all.map((entry) => tearDownEntry(entry)));
|
|
1394
1440
|
}
|
|
1395
1441
|
ids() {
|
|
1396
1442
|
return [...this.entries.keys()];
|
|
@@ -1402,7 +1448,7 @@ function ptyRegistry() {
|
|
|
1402
1448
|
g[REGISTRY_KEY] = new PtyRegistry;
|
|
1403
1449
|
return g[REGISTRY_KEY];
|
|
1404
1450
|
}
|
|
1405
|
-
var
|
|
1451
|
+
var TEARDOWN_TIMEOUT_MS = 5000, REGISTRY_KEY;
|
|
1406
1452
|
var init_registry = __esm(() => {
|
|
1407
1453
|
init_pty();
|
|
1408
1454
|
init_ring_buffer();
|
|
@@ -1630,7 +1676,9 @@ function rowToSession(row) {
|
|
|
1630
1676
|
transcriptPath: row.transcriptPath ?? undefined,
|
|
1631
1677
|
status: row.status,
|
|
1632
1678
|
awaitingInput: row.awaitingInput,
|
|
1633
|
-
worktreePath: row.worktreePath
|
|
1679
|
+
worktreePath: row.worktreePath,
|
|
1680
|
+
title: row.title ?? undefined,
|
|
1681
|
+
createdAt: row.createdAt ?? undefined
|
|
1634
1682
|
});
|
|
1635
1683
|
}
|
|
1636
1684
|
function tryGetFeature(ctx, id) {
|
|
@@ -2094,7 +2142,7 @@ function sleep(ms, signal) {
|
|
|
2094
2142
|
var APP_READY_BUDGET_MS = 120000, APP_READY_INTERVAL_MS = 1000, APP_READY_ATTEMPT_TIMEOUT_MS = 5000;
|
|
2095
2143
|
|
|
2096
2144
|
// src/services/drive-hooks.ts
|
|
2097
|
-
import { spawn as
|
|
2145
|
+
import { spawn as spawn3 } from "child_process";
|
|
2098
2146
|
function tailLines(text2, max2 = OUTPUT_TAIL_LINES) {
|
|
2099
2147
|
const lines = text2.replace(/\r\n/g, `
|
|
2100
2148
|
`).split(`
|
|
@@ -2115,7 +2163,7 @@ function hookSpawnTarget(command) {
|
|
|
2115
2163
|
return { file: "/bin/sh", args: ["-c", command], verbatim: false };
|
|
2116
2164
|
}
|
|
2117
2165
|
function runDriveHook(command, opts) {
|
|
2118
|
-
const spawnFn = opts.spawnFn ??
|
|
2166
|
+
const spawnFn = opts.spawnFn ?? spawn3;
|
|
2119
2167
|
const now = opts.now ?? Date.now;
|
|
2120
2168
|
const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
|
|
2121
2169
|
const startedAt = now();
|
|
@@ -3508,7 +3556,9 @@ function createSessionRow(ctx, input) {
|
|
|
3508
3556
|
ccSessionId: null,
|
|
3509
3557
|
transcriptPath: null,
|
|
3510
3558
|
status: "launching",
|
|
3511
|
-
worktreePath: input.worktreePath
|
|
3559
|
+
worktreePath: input.worktreePath,
|
|
3560
|
+
title: null,
|
|
3561
|
+
createdAt: Date.now()
|
|
3512
3562
|
}).returning().get();
|
|
3513
3563
|
return rowToSession(row);
|
|
3514
3564
|
}
|
|
@@ -3795,6 +3845,12 @@ function activeProjectSession(ctx, projectId, kind) {
|
|
|
3795
3845
|
const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
|
|
3796
3846
|
return row ? rowToSession(row) : null;
|
|
3797
3847
|
}
|
|
3848
|
+
function projectSessions(ctx, projectId, kind) {
|
|
3849
|
+
return ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind))).orderBy(desc3(sql2`rowid`)).all().map(rowToSession);
|
|
3850
|
+
}
|
|
3851
|
+
function setSessionTitle(ctx, id, title) {
|
|
3852
|
+
ctx.db.update(sessions).set({ title }).where(eq5(sessions.id, id)).run();
|
|
3853
|
+
}
|
|
3798
3854
|
function hasCompletedProjectSession(ctx, projectId, kind) {
|
|
3799
3855
|
return ctx.db.select({ id: sessions.id }).from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"))).limit(1).get() !== undefined;
|
|
3800
3856
|
}
|
|
@@ -4512,7 +4568,7 @@ function renderProjectPrompt(brief) {
|
|
|
4512
4568
|
"## runcastle MCP tools",
|
|
4513
4569
|
"Four, and none of the feature pipeline's \u2014 a session with no feature has no business",
|
|
4514
4570
|
"advancing one through a gate:",
|
|
4515
|
-
"- `create_feature({ title, oneLiner, baseBranch?, brief?,
|
|
4571
|
+
"- `create_feature({ title, oneLiner, baseBranch?, brief?, tickets? })` \u2014 the point of",
|
|
4516
4572
|
" this session. It does NOT open a terminal on what it creates; the new card appearing",
|
|
4517
4573
|
" in the rail is the feedback, and the human decides what to work on next.",
|
|
4518
4574
|
"- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
|
|
@@ -7001,13 +7057,47 @@ async function startDraft(ctx, featureId, opts = {}) {
|
|
|
7001
7057
|
}
|
|
7002
7058
|
return started;
|
|
7003
7059
|
}
|
|
7060
|
+
function quickTicketTitle(prose) {
|
|
7061
|
+
const line = prose.split(`
|
|
7062
|
+
`)[0].trim();
|
|
7063
|
+
if (line.length <= TICKET_TITLE_MAX)
|
|
7064
|
+
return line;
|
|
7065
|
+
const cut = line.slice(0, TICKET_TITLE_MAX);
|
|
7066
|
+
const space = cut.lastIndexOf(" ");
|
|
7067
|
+
return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}\u2026`;
|
|
7068
|
+
}
|
|
7069
|
+
function quickBrief(title, proses) {
|
|
7070
|
+
const body = proses.length === 1 ? proses[0] : proses.map((p, i) => `## Ticket ${i + 1}
|
|
7071
|
+
|
|
7072
|
+
${p}`).join(`
|
|
7073
|
+
|
|
7074
|
+
`);
|
|
7075
|
+
return `# ${title}
|
|
7076
|
+
|
|
7077
|
+
${body}
|
|
7078
|
+
`;
|
|
7079
|
+
}
|
|
7080
|
+
function quickReviewTicket(proses) {
|
|
7081
|
+
return {
|
|
7082
|
+
title: "Review the integrated change",
|
|
7083
|
+
goal: "Review the integrated feature branch and report what you find. Code-review the branch's " + "diff against its base \u2014 always, whatever this change turned out to be \u2014 and additionally " + "drive the app when the diff touches something a human can operate. Write one note per " + "finding; finding bugs is a successful review, and the notes are the deliverable.",
|
|
7084
|
+
context: "This feature came through the quick-change door, so there was no grill session and there " + "is no spec.md or decisions.md to review it against: the whole statement of intent is the " + `${proses.length === 1 ? "sentence" : `${proses.length} sentences`} the human typed, which ` + "are the other tickets in this batch and are reproduced verbatim in brief.md. Nobody " + "prescribed a walkthrough for you either \u2014 judge from the diff whether there is anything " + "drivable, and if there is not, say so and let the code review stand alone. Your digest is " + "the lap's prose summary of what landed; the review page leads with it.",
|
|
7085
|
+
acceptanceCriteria: [
|
|
7086
|
+
"The branch's diff against its base is code-reviewed on both axes \u2014 the repo's own " + "standards, and the change against what was asked for.",
|
|
7087
|
+
...proses.map((prose) => `Landed and does what it says: ${prose}`)
|
|
7088
|
+
],
|
|
7089
|
+
seams: [],
|
|
7090
|
+
blockedBy: proses.map((_, i) => i + 1),
|
|
7091
|
+
kind: "review"
|
|
7092
|
+
};
|
|
7093
|
+
}
|
|
7004
7094
|
async function quickChange(ctx, input) {
|
|
7005
7095
|
const project = requireProjectById(ctx, input.projectId);
|
|
7006
7096
|
const title = input.title.trim();
|
|
7007
|
-
const
|
|
7097
|
+
const proses = input.tickets.map((t) => t.trim()).filter((t) => t !== "");
|
|
7008
7098
|
if (!title)
|
|
7009
7099
|
throw new InvalidInputError("a quick change needs a title");
|
|
7010
|
-
if (
|
|
7100
|
+
if (proses.length === 0)
|
|
7011
7101
|
throw new InvalidInputError("a quick change needs a sentence describing the change");
|
|
7012
7102
|
const slug = uniqueSlug(ctx, project.id, title);
|
|
7013
7103
|
const branch = `feature/${slug}`;
|
|
@@ -7018,7 +7108,7 @@ async function quickChange(ctx, input) {
|
|
|
7018
7108
|
projectId: project.id,
|
|
7019
7109
|
slug,
|
|
7020
7110
|
title,
|
|
7021
|
-
oneLiner:
|
|
7111
|
+
oneLiner: proses[0].split(`
|
|
7022
7112
|
`)[0].trim(),
|
|
7023
7113
|
mapped: false,
|
|
7024
7114
|
lap: 1,
|
|
@@ -7034,25 +7124,25 @@ async function quickChange(ctx, input) {
|
|
|
7034
7124
|
message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
|
|
7035
7125
|
data: { slug, branch, baseBranch, branchReady }
|
|
7036
7126
|
});
|
|
7037
|
-
scaffoldDocs(ctx, feature, { brief:
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
const [ticket] = storeTickets(ctx, feature.id, [
|
|
7042
|
-
{
|
|
7043
|
-
title,
|
|
7127
|
+
scaffoldDocs(ctx, feature, { brief: quickBrief(title, proses) });
|
|
7128
|
+
const stored = storeTickets(ctx, feature.id, [
|
|
7129
|
+
...proses.map((prose) => ({
|
|
7130
|
+
title: quickTicketTitle(prose),
|
|
7044
7131
|
goal: prose,
|
|
7045
7132
|
context: prose,
|
|
7046
7133
|
acceptanceCriteria: [prose],
|
|
7047
7134
|
seams: [],
|
|
7048
7135
|
blockedBy: []
|
|
7049
|
-
}
|
|
7136
|
+
})),
|
|
7137
|
+
quickReviewTicket(proses)
|
|
7050
7138
|
]);
|
|
7139
|
+
const typed = stored.slice(0, proses.length);
|
|
7140
|
+
const review = stored[stored.length - 1];
|
|
7141
|
+
const tally = typed.length === 1 ? "one ticket" : `${typed.length} tickets`;
|
|
7051
7142
|
emit(ctx, feature.id, {
|
|
7052
7143
|
type: "feature.quick_change",
|
|
7053
|
-
message: `quick change \u2014 born at implementation on lap 1 with
|
|
7054
|
-
|
|
7055
|
-
data: { slug, ticketSeq: ticket.seq, phase: "implementation" }
|
|
7144
|
+
message: `quick change \u2014 born at implementation on lap 1 with ${tally} (${typed.map((t) => `#${t.seq}`).join(", ")}) plus a review ticket (#${review.seq}); no grill session, no spec.md`,
|
|
7145
|
+
data: { slug, ticketSeqs: stored.map((t) => t.seq), phase: "implementation" }
|
|
7056
7146
|
});
|
|
7057
7147
|
if (branchReady) {
|
|
7058
7148
|
try {
|
|
@@ -7392,6 +7482,7 @@ function slugify(title) {
|
|
|
7392
7482
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7393
7483
|
return slug || "feature";
|
|
7394
7484
|
}
|
|
7485
|
+
var TICKET_TITLE_MAX = 72;
|
|
7395
7486
|
var init_features = __esm(() => {
|
|
7396
7487
|
init_src();
|
|
7397
7488
|
init_paths();
|
|
@@ -7527,20 +7618,47 @@ function promotionTicket(feature, note) {
|
|
|
7527
7618
|
blockedBy: []
|
|
7528
7619
|
};
|
|
7529
7620
|
}
|
|
7621
|
+
function freezeAsTickets(ctx, noteIds) {
|
|
7622
|
+
if (noteIds.length === 0)
|
|
7623
|
+
throw new InvalidInputError("no notes selected to promote");
|
|
7624
|
+
if (new Set(noteIds).size !== noteIds.length)
|
|
7625
|
+
throw new InvalidInputError("the same note appears twice in the selection");
|
|
7626
|
+
const selected = noteIds.map((id) => getNote(ctx, id));
|
|
7627
|
+
for (const note of selected)
|
|
7628
|
+
assertOpen(note, "promote");
|
|
7629
|
+
const featureId = selected[0].featureId;
|
|
7630
|
+
if (selected.some((n) => n.featureId !== featureId))
|
|
7631
|
+
throw new InvalidInputError("every note in a promotion must belong to the same feature");
|
|
7632
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
7633
|
+
const tickets2 = storeTickets(ctx, featureId, selected.map((note) => promotionTicket(feature, note)));
|
|
7634
|
+
const now = Date.now();
|
|
7635
|
+
selected.forEach((note, i) => {
|
|
7636
|
+
ctx.db.update(testNotes).set({ status: "promoted", ticketId: tickets2[i].id, updatedAt: now }).where(eq10(testNotes.id, note.id)).run();
|
|
7637
|
+
});
|
|
7638
|
+
return { feature, notes: selected.map((n) => getNote(ctx, n.id)), tickets: tickets2 };
|
|
7639
|
+
}
|
|
7530
7640
|
function promoteNote(ctx, noteId) {
|
|
7531
|
-
const
|
|
7532
|
-
|
|
7533
|
-
const
|
|
7534
|
-
|
|
7535
|
-
ctx.db.update(testNotes).set({ status: "promoted", ticketId: ticket.id, updatedAt: Date.now() }).where(eq10(testNotes.id, noteId)).run();
|
|
7536
|
-
emit(ctx, current2.featureId, {
|
|
7641
|
+
const { feature, notes, tickets: tickets2 } = freezeAsTickets(ctx, [noteId]);
|
|
7642
|
+
const [note] = notes;
|
|
7643
|
+
const [ticket] = tickets2;
|
|
7644
|
+
emit(ctx, feature.id, {
|
|
7537
7645
|
type: "note.promoted",
|
|
7538
7646
|
message: `note promoted to ticket ${ticket.seq}`,
|
|
7539
7647
|
ticketId: ticket.id,
|
|
7540
7648
|
data: { noteId, seq: ticket.seq }
|
|
7541
7649
|
});
|
|
7542
7650
|
renderTestNotes(ctx, feature);
|
|
7543
|
-
return { note
|
|
7651
|
+
return { note, ticket };
|
|
7652
|
+
}
|
|
7653
|
+
function promoteMany(ctx, noteIds) {
|
|
7654
|
+
const { feature, notes, tickets: tickets2 } = freezeAsTickets(ctx, noteIds);
|
|
7655
|
+
emit(ctx, feature.id, {
|
|
7656
|
+
type: "notes.promoted",
|
|
7657
|
+
message: `${notes.length} note${notes.length === 1 ? "" : "s"} promoted to ticket${tickets2.length === 1 ? ` ${tickets2[0].seq}` : `s ${tickets2.map((t) => t.seq).join(", ")}`}`,
|
|
7658
|
+
data: { noteIds, seqs: tickets2.map((t) => t.seq) }
|
|
7659
|
+
});
|
|
7660
|
+
renderTestNotes(ctx, feature);
|
|
7661
|
+
return { notes, tickets: tickets2 };
|
|
7544
7662
|
}
|
|
7545
7663
|
function noteLine(ctx, note) {
|
|
7546
7664
|
if (note.status === "open")
|
|
@@ -7807,17 +7925,17 @@ function createFeatureProject(ctx, session, input) {
|
|
|
7807
7925
|
if (!DRAFTING_KINDS.includes(session.kind)) {
|
|
7808
7926
|
throw new GateError(`a ${session.kind} session is read-only and may not create features, drafts included. ` + "Tell the human what is worth capturing; the project session is where features are made.");
|
|
7809
7927
|
}
|
|
7810
|
-
if (!input.draft || input.
|
|
7811
|
-
throw new GateError(`a ${session.kind} session may only PARK a feature here: call create_feature with ` + "`draft: true` and a `brief` carrying why you deferred it. Cutting a branch \u2014 a full " + "create or the quick-change `
|
|
7928
|
+
if (!input.draft || input.tickets) {
|
|
7929
|
+
throw new GateError(`a ${session.kind} session may only PARK a feature here: call create_feature with ` + "`draft: true` and a `brief` carrying why you deferred it. Cutting a branch \u2014 a full " + "create or the quick-change `tickets` shape \u2014 belongs to the project session; tell the " + "human to open it.");
|
|
7812
7930
|
}
|
|
7813
7931
|
return projectForFeature(ctx, getFeatureRow(ctx, requireFeatureId(session)));
|
|
7814
7932
|
}
|
|
7815
7933
|
async function toolCreateFeature(ctx, session, input) {
|
|
7816
7934
|
const project = createFeatureProject(ctx, session, input);
|
|
7817
|
-
const feature = input.
|
|
7935
|
+
const feature = input.tickets ? await quickChange(ctx, {
|
|
7818
7936
|
projectId: project.id,
|
|
7819
7937
|
title: input.title,
|
|
7820
|
-
|
|
7938
|
+
tickets: input.tickets,
|
|
7821
7939
|
baseBranch: input.baseBranch
|
|
7822
7940
|
}) : await createFeature(ctx, {
|
|
7823
7941
|
projectId: project.id,
|
|
@@ -7957,7 +8075,7 @@ function buildMcpServer() {
|
|
|
7957
8075
|
});
|
|
7958
8076
|
server.registerTool("add_test_note", {
|
|
7959
8077
|
title: "Add a review note",
|
|
7960
|
-
description: "Record one review finding on the feature you are reviewing. It lands in the same notes " + "the human writes while test-driving \u2014 attributed to you \u2014 so it shows up in the review " + "panel
|
|
8078
|
+
description: "Record one review finding on the feature you are reviewing. It lands in the same notes " + "the human writes while test-driving \u2014 attributed to you \u2014 so it shows up in the review " + "panel, where the human triages every open note at once into fix tickets or the next " + "lap\u2019s session. One note per finding, written " + "as an observation the human can reproduce (what you did, what happened, what you " + "expected), plus a closing note summarising the pass. Findings are not failure: report " + "what you saw and let the human decide.",
|
|
7961
8079
|
inputSchema: { text: z3.string().min(1) }
|
|
7962
8080
|
}, async (args, extra) => {
|
|
7963
8081
|
const rc = await resolveRunCaller(extra);
|
|
@@ -7975,14 +8093,14 @@ function buildMcpServer() {
|
|
|
7975
8093
|
});
|
|
7976
8094
|
server.registerTool("create_feature", {
|
|
7977
8095
|
title: "Create a feature",
|
|
7978
|
-
description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`draft: true` to PARK it instead of starting it \u2014 a row and its brief, no branch and no " + "files, until the human clicks Start; ask them per feature whether to start it now or " + "park it. Pass " + "`
|
|
8096
|
+
description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`draft: true` to PARK it instead of starting it \u2014 a row and its brief, no branch and no " + "files, until the human clicks Start; ask them per feature whether to start it now or " + "park it. Pass " + "`tickets: [prose, ...]` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates ONE feature at the implementation phase carrying a ticket per prose (plus the " + "review ticket every batch closes with), and whose brief.md is those proses themselves " + "(so `brief` is unused there). Send every sentence of one quick change in a single call: " + "calling this " + "once per ticket would make one feature per ticket. From a feature session (ideation, " + "revisit, waypoint, converge) this tool parks drafts and nothing else: `draft: true` is " + "required and the `tickets` shape is refused, so deflect scope creep here and leave full " + "creation to the project session. This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
|
|
7979
8097
|
inputSchema: {
|
|
7980
8098
|
title: z3.string().min(1),
|
|
7981
8099
|
oneLiner: z3.string(),
|
|
7982
8100
|
baseBranch: z3.string().optional(),
|
|
7983
8101
|
brief: z3.string().optional(),
|
|
7984
8102
|
draft: z3.boolean().optional(),
|
|
7985
|
-
|
|
8103
|
+
tickets: z3.array(z3.string()).min(1).optional()
|
|
7986
8104
|
}
|
|
7987
8105
|
}, async (args, extra) => {
|
|
7988
8106
|
const rs = await resolveCtxSession(extra);
|
|
@@ -9328,17 +9446,30 @@ async function launchProjectSession(ctx, input, opts = {}) {
|
|
|
9328
9446
|
kind: "project",
|
|
9329
9447
|
worktreePath
|
|
9330
9448
|
});
|
|
9331
|
-
const
|
|
9449
|
+
const resumeRowId = input.fresh ? undefined : input.resumeSessionId;
|
|
9450
|
+
const resumeSessionId = resumeRowId ? getSessionRow(ctx, resumeRowId)?.ccSessionId ?? undefined : undefined;
|
|
9332
9451
|
emitProject(ctx, project.id, {
|
|
9333
9452
|
type: "session.launching",
|
|
9334
|
-
message: "launching project
|
|
9335
|
-
data: {
|
|
9453
|
+
message: resumeSessionId ? "resuming a project conversation" : "launching a new project chat",
|
|
9454
|
+
data: {
|
|
9455
|
+
sessionId: session.id,
|
|
9456
|
+
kind: "project",
|
|
9457
|
+
worktreePath,
|
|
9458
|
+
branch: PROJECT_BRANCH,
|
|
9459
|
+
...resumeSessionId ? {} : { fresh: true }
|
|
9460
|
+
}
|
|
9336
9461
|
});
|
|
9337
9462
|
if (resumeSessionId) {
|
|
9338
9463
|
emitProject(ctx, project.id, {
|
|
9339
9464
|
type: "session.resumed",
|
|
9340
9465
|
message: "resuming the previous project conversation",
|
|
9341
|
-
data: { sessionId: session.id, resumeSessionId }
|
|
9466
|
+
data: { sessionId: session.id, resumeSessionId, resumedFrom: resumeRowId }
|
|
9467
|
+
});
|
|
9468
|
+
} else if (resumeRowId) {
|
|
9469
|
+
emitProject(ctx, project.id, {
|
|
9470
|
+
type: "session.resume_unavailable",
|
|
9471
|
+
message: "that conversation was never picked up by Claude Code \u2014 starting a new chat instead",
|
|
9472
|
+
data: { sessionId: session.id, resumedFrom: resumeRowId }
|
|
9342
9473
|
});
|
|
9343
9474
|
}
|
|
9344
9475
|
const artifacts = await writeSessionArtifacts({
|
|
@@ -9663,7 +9794,7 @@ var init_feature = __esm(() => {
|
|
|
9663
9794
|
quickChange: publicProcedure.input(z6.object({
|
|
9664
9795
|
projectId: z6.string(),
|
|
9665
9796
|
title: z6.string().min(1),
|
|
9666
|
-
|
|
9797
|
+
tickets: z6.array(z6.string()).min(1),
|
|
9667
9798
|
baseBranch: z6.string().optional()
|
|
9668
9799
|
})).mutation(({ ctx, input }) => quickChange(ctx, input)),
|
|
9669
9800
|
list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
|
|
@@ -9863,6 +9994,98 @@ var init_fsbrowse = __esm(() => {
|
|
|
9863
9994
|
COMMON_DIRS = ["Projects", "projects", "code", "Code", "src", "dev", "repos", "git"];
|
|
9864
9995
|
});
|
|
9865
9996
|
|
|
9997
|
+
// src/services/transcripts.ts
|
|
9998
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
9999
|
+
function contentText(content) {
|
|
10000
|
+
if (typeof content === "string")
|
|
10001
|
+
return content.trim();
|
|
10002
|
+
if (!Array.isArray(content))
|
|
10003
|
+
return "";
|
|
10004
|
+
return content.map((block) => block && typeof block === "object" && block.type === "text" ? String(block.text ?? "") : "").join("").trim();
|
|
10005
|
+
}
|
|
10006
|
+
function parseTranscript(jsonl) {
|
|
10007
|
+
const turns = [];
|
|
10008
|
+
for (const line of jsonl.split(`
|
|
10009
|
+
`)) {
|
|
10010
|
+
const trimmed = line.trim();
|
|
10011
|
+
if (!trimmed)
|
|
10012
|
+
continue;
|
|
10013
|
+
let entry;
|
|
10014
|
+
try {
|
|
10015
|
+
entry = JSON.parse(trimmed);
|
|
10016
|
+
} catch {
|
|
10017
|
+
continue;
|
|
10018
|
+
}
|
|
10019
|
+
if (entry.type !== "user" && entry.type !== "assistant")
|
|
10020
|
+
continue;
|
|
10021
|
+
if (entry.isMeta)
|
|
10022
|
+
continue;
|
|
10023
|
+
const text2 = contentText(entry.message?.content);
|
|
10024
|
+
if (!text2)
|
|
10025
|
+
continue;
|
|
10026
|
+
turns.push({ role: entry.type, text: text2 });
|
|
10027
|
+
}
|
|
10028
|
+
return turns;
|
|
10029
|
+
}
|
|
10030
|
+
function readTranscript2(path) {
|
|
10031
|
+
if (!path)
|
|
10032
|
+
return [];
|
|
10033
|
+
try {
|
|
10034
|
+
return parseTranscript(readFileSync10(path, "utf8"));
|
|
10035
|
+
} catch {
|
|
10036
|
+
return [];
|
|
10037
|
+
}
|
|
10038
|
+
}
|
|
10039
|
+
var init_transcripts = () => {};
|
|
10040
|
+
|
|
10041
|
+
// src/services/conversations.ts
|
|
10042
|
+
function withoutKickoff(turns, kind) {
|
|
10043
|
+
const kickoff = kickoffLineFor(kind);
|
|
10044
|
+
return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
|
|
10045
|
+
}
|
|
10046
|
+
function deriveTitle(turns) {
|
|
10047
|
+
const first = withoutKickoff(turns, "project").find((turn) => turn.role === "user");
|
|
10048
|
+
return first ? elide(first.text) : null;
|
|
10049
|
+
}
|
|
10050
|
+
function elide(text2) {
|
|
10051
|
+
const line = text2.replace(/\s+/g, " ").trim();
|
|
10052
|
+
if (line.length <= TITLE_MAX2)
|
|
10053
|
+
return line;
|
|
10054
|
+
return `${line.slice(0, TITLE_MAX2).trimEnd()}\u2026`;
|
|
10055
|
+
}
|
|
10056
|
+
function untitled(createdAt) {
|
|
10057
|
+
return createdAt ? `Chat from ${new Date(createdAt).toISOString().slice(0, 10)}` : "Untitled chat";
|
|
10058
|
+
}
|
|
10059
|
+
function listProjectConversations(ctx, projectId) {
|
|
10060
|
+
return projectSessions(ctx, projectId, "project").map((session) => {
|
|
10061
|
+
const createdAt = session.createdAt ?? null;
|
|
10062
|
+
let title = session.title ?? null;
|
|
10063
|
+
if (!title) {
|
|
10064
|
+
title = deriveTitle(readTranscript2(session.transcriptPath));
|
|
10065
|
+
if (title)
|
|
10066
|
+
setSessionTitle(ctx, session.id, title);
|
|
10067
|
+
}
|
|
10068
|
+
return {
|
|
10069
|
+
id: session.id,
|
|
10070
|
+
title: title ?? untitled(createdAt),
|
|
10071
|
+
createdAt,
|
|
10072
|
+
status: session.status,
|
|
10073
|
+
resumable: !!session.ccSessionId
|
|
10074
|
+
};
|
|
10075
|
+
});
|
|
10076
|
+
}
|
|
10077
|
+
function conversationTranscript(ctx, sessionId) {
|
|
10078
|
+
const session = getSessionRow(ctx, sessionId);
|
|
10079
|
+
if (!session)
|
|
10080
|
+
return [];
|
|
10081
|
+
return withoutKickoff(readTranscript2(session.transcriptPath), session.kind);
|
|
10082
|
+
}
|
|
10083
|
+
var TITLE_MAX2 = 60;
|
|
10084
|
+
var init_conversations = __esm(() => {
|
|
10085
|
+
init_sessions();
|
|
10086
|
+
init_transcripts();
|
|
10087
|
+
});
|
|
10088
|
+
|
|
9866
10089
|
// src/services/projects.ts
|
|
9867
10090
|
import { existsSync as existsSync17 } from "fs";
|
|
9868
10091
|
import { basename as basename2, join as join18 } from "path";
|
|
@@ -9980,6 +10203,7 @@ var init_project = __esm(() => {
|
|
|
9980
10203
|
init_prep();
|
|
9981
10204
|
init_launcher();
|
|
9982
10205
|
init_sessions();
|
|
10206
|
+
init_conversations();
|
|
9983
10207
|
init_projects();
|
|
9984
10208
|
init_repo();
|
|
9985
10209
|
init_context();
|
|
@@ -9993,8 +10217,14 @@ var init_project = __esm(() => {
|
|
|
9993
10217
|
rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: ProjectName })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
|
|
9994
10218
|
talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string(), fresh: z7.boolean().optional() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId, fresh: input.fresh })),
|
|
9995
10219
|
prepSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
|
|
9996
|
-
talkToProject: publicProcedure.input(z7.object({
|
|
10220
|
+
talkToProject: publicProcedure.input(z7.object({
|
|
10221
|
+
projectId: z7.string(),
|
|
10222
|
+
fresh: z7.boolean().optional(),
|
|
10223
|
+
resumeSessionId: z7.string().optional()
|
|
10224
|
+
})).mutation(({ ctx, input }) => launchProjectSession(ctx, input)),
|
|
9997
10225
|
projectSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
|
|
10226
|
+
conversations: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listProjectConversations(ctx, input.projectId)),
|
|
10227
|
+
conversationTranscript: publicProcedure.input(z7.object({ sessionId: z7.string() })).query(({ ctx, input }) => conversationTranscript(ctx, input.sessionId)),
|
|
9998
10228
|
prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId))),
|
|
9999
10229
|
dryRunStop: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => dryRunDrive(ctx, requireProjectById(ctx, input.projectId), "stop"))
|
|
10000
10230
|
});
|
|
@@ -10020,7 +10250,7 @@ var init_run = __esm(() => {
|
|
|
10020
10250
|
});
|
|
10021
10251
|
|
|
10022
10252
|
// src/services/settings.ts
|
|
10023
|
-
import { existsSync as existsSync18, mkdirSync as mkdirSync11, readFileSync as
|
|
10253
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
|
|
10024
10254
|
import { dirname as dirname10 } from "path";
|
|
10025
10255
|
import * as z9 from "zod";
|
|
10026
10256
|
import { eq as eq14 } from "drizzle-orm";
|
|
@@ -10028,7 +10258,7 @@ function readRawConfig(configFile) {
|
|
|
10028
10258
|
if (!existsSync18(configFile))
|
|
10029
10259
|
return {};
|
|
10030
10260
|
try {
|
|
10031
|
-
const parsed = JSON.parse(
|
|
10261
|
+
const parsed = JSON.parse(readFileSync11(configFile, "utf8"));
|
|
10032
10262
|
if (typeof parsed !== "object" || parsed === null)
|
|
10033
10263
|
return {};
|
|
10034
10264
|
return foldLegacyModelConfig(parsed);
|
|
@@ -10549,13 +10779,13 @@ var init_doctor = __esm(() => {
|
|
|
10549
10779
|
});
|
|
10550
10780
|
|
|
10551
10781
|
// src/doctor/system-exec.ts
|
|
10552
|
-
import { spawn as
|
|
10782
|
+
import { spawn as spawn4 } from "child_process";
|
|
10553
10783
|
function createSystemExec(opts = {}) {
|
|
10554
10784
|
return (command, args) => new Promise((resolve11) => {
|
|
10555
10785
|
const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
|
|
10556
10786
|
let stdout = "";
|
|
10557
10787
|
let stderr = "";
|
|
10558
|
-
const child =
|
|
10788
|
+
const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
|
|
10559
10789
|
child.stdout?.on("data", (d) => {
|
|
10560
10790
|
stdout += d.toString();
|
|
10561
10791
|
});
|
|
@@ -10575,7 +10805,7 @@ var init_system_exec = __esm(() => {
|
|
|
10575
10805
|
});
|
|
10576
10806
|
|
|
10577
10807
|
// src/services/setup.ts
|
|
10578
|
-
import { copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as
|
|
10808
|
+
import { copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync12, readFileSync as readFileSync12, readdirSync as readdirSync6, writeFileSync as writeFileSync8 } from "fs";
|
|
10579
10809
|
import { dirname as dirname11, join as join19 } from "path";
|
|
10580
10810
|
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
10581
10811
|
async function writeGitIdentity(exec, identity) {
|
|
@@ -10656,7 +10886,7 @@ function resolveSandcastleBin() {
|
|
|
10656
10886
|
for (let hops = 0;hops < 6; hops++) {
|
|
10657
10887
|
const manifestPath = join19(dir, "package.json");
|
|
10658
10888
|
if (existsSync19(manifestPath)) {
|
|
10659
|
-
const manifest = JSON.parse(
|
|
10889
|
+
const manifest = JSON.parse(readFileSync12(manifestPath, "utf8"));
|
|
10660
10890
|
if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
|
|
10661
10891
|
return join19(dir, manifest.bin.sandcastle);
|
|
10662
10892
|
}
|
|
@@ -10733,7 +10963,7 @@ function createTokenVerifier(exec) {
|
|
|
10733
10963
|
function fileAfkTokenIo(exec) {
|
|
10734
10964
|
const path = envPath();
|
|
10735
10965
|
return {
|
|
10736
|
-
read: () => existsSync19(path) ?
|
|
10966
|
+
read: () => existsSync19(path) ? readFileSync12(path, "utf8") : "",
|
|
10737
10967
|
write: (content) => {
|
|
10738
10968
|
mkdirSync12(dirname11(path), { recursive: true });
|
|
10739
10969
|
writeFileSync8(path, content, "utf8");
|
|
@@ -10813,7 +11043,8 @@ var init_test_notes2 = __esm(() => {
|
|
|
10813
11043
|
edit: publicProcedure.input(z12.object({ noteId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
|
|
10814
11044
|
remove: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
|
|
10815
11045
|
toggle: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
|
|
10816
|
-
promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId))
|
|
11046
|
+
promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId)),
|
|
11047
|
+
promoteMany: publicProcedure.input(z12.object({ noteIds: z12.array(z12.string()).min(1) })).mutation(({ ctx, input }) => promoteMany(ctx, input.noteIds))
|
|
10817
11048
|
});
|
|
10818
11049
|
});
|
|
10819
11050
|
|