chapterhouse 0.5.1 → 0.6.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/.pr-types.json +14 -0
- package/README.md +6 -0
- package/dist/api/server.js +5 -3
- package/dist/cli.js +4 -2
- package/dist/config.js +75 -13
- package/dist/config.test.js +73 -0
- package/dist/copilot/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +31 -212
- package/dist/copilot/orchestrator.test.js +111 -0
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.js +43 -8
- package/dist/copilot/router.test.js +60 -18
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +65 -39
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/daemon.js +7 -2
- package/dist/integrations/team-push.js +8 -1
- package/dist/integrations/teams-notify.js +8 -1
- package/dist/memory/housekeeping.js +73 -25
- package/dist/memory/housekeeping.test.js +95 -3
- package/dist/memory/inbox.test.js +178 -0
- package/dist/memory/tiering.test.js +323 -0
- package/dist/mode-context.js +28 -0
- package/dist/mode-context.test.js +42 -0
- package/dist/setup.js +162 -95
- package/dist/setup.test.js +139 -0
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/dist/wiki/team-sync.js +8 -1
- package/package.json +6 -1
- package/web/dist/assets/{index-BfHqP3-C.js → index-B5oDsQ5y.js} +84 -84
- package/web/dist/assets/index-B5oDsQ5y.js.map +1 -0
- package/web/dist/assets/index-DknKAtDS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BfHqP3-C.js.map +0 -1
- package/web/dist/assets/index-_O6AoWOS.css +0 -10
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { runSprintMerge, parseSprintMergeArgs } from "./sprint-merge.js";
|
|
4
|
+
test("runSprintMerge merges PRs serially and refreshes remaining branches after each merge", async () => {
|
|
5
|
+
const commands = [];
|
|
6
|
+
const runner = (command, args) => {
|
|
7
|
+
commands.push([command, ...args].join(" "));
|
|
8
|
+
if (command === "gh" && args[0] === "pr" && args[1] === "view") {
|
|
9
|
+
return JSON.stringify({ state: "OPEN", mergeable: "MERGEABLE", reviewDecision: "APPROVED" });
|
|
10
|
+
}
|
|
11
|
+
return "";
|
|
12
|
+
};
|
|
13
|
+
const lines = await runSprintMerge([264, 265, 266], { dryRun: false, runner });
|
|
14
|
+
assert.deepEqual(commands, [
|
|
15
|
+
"gh pr view 264 --json state,mergeable,reviewDecision",
|
|
16
|
+
"gh pr merge 264 --squash",
|
|
17
|
+
"git fetch origin main",
|
|
18
|
+
"gh pr update-branch 265",
|
|
19
|
+
"gh pr update-branch 266",
|
|
20
|
+
"gh pr view 265 --json state,mergeable,reviewDecision",
|
|
21
|
+
"gh pr merge 265 --squash",
|
|
22
|
+
"git fetch origin main",
|
|
23
|
+
"gh pr update-branch 266",
|
|
24
|
+
"gh pr view 266 --json state,mergeable,reviewDecision",
|
|
25
|
+
"gh pr merge 266 --squash",
|
|
26
|
+
"git fetch origin main",
|
|
27
|
+
]);
|
|
28
|
+
assert.match(lines.join("\n"), /Processing #264/);
|
|
29
|
+
assert.match(lines.join("\n"), /Merged #265/);
|
|
30
|
+
assert.match(lines.join("\n"), /Refreshed #266/);
|
|
31
|
+
});
|
|
32
|
+
test("runSprintMerge continues after merge failures and skips non-mergeable PRs", async () => {
|
|
33
|
+
const commands = [];
|
|
34
|
+
const runner = (command, args) => {
|
|
35
|
+
commands.push([command, ...args].join(" "));
|
|
36
|
+
if (command === "gh" && args[0] === "pr" && args[1] === "view") {
|
|
37
|
+
const prNumber = args[2];
|
|
38
|
+
if (prNumber === "264") {
|
|
39
|
+
return JSON.stringify({ state: "OPEN", mergeable: "MERGEABLE", reviewDecision: "APPROVED" });
|
|
40
|
+
}
|
|
41
|
+
if (prNumber === "265") {
|
|
42
|
+
return JSON.stringify({ state: "OPEN", mergeable: "CONFLICTING", reviewDecision: "APPROVED" });
|
|
43
|
+
}
|
|
44
|
+
return JSON.stringify({ state: "OPEN", mergeable: "MERGEABLE", reviewDecision: "REVIEW_REQUIRED" });
|
|
45
|
+
}
|
|
46
|
+
if (command === "gh" && args[0] === "pr" && args[1] === "merge" && args[2] === "264") {
|
|
47
|
+
throw new Error("merge failed");
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
};
|
|
51
|
+
const lines = await runSprintMerge([264, 265, 266], { dryRun: false, runner });
|
|
52
|
+
assert.deepEqual(commands, [
|
|
53
|
+
"gh pr view 264 --json state,mergeable,reviewDecision",
|
|
54
|
+
"gh pr merge 264 --squash",
|
|
55
|
+
"gh pr view 265 --json state,mergeable,reviewDecision",
|
|
56
|
+
"gh pr view 266 --json state,mergeable,reviewDecision",
|
|
57
|
+
"gh pr merge 266 --squash",
|
|
58
|
+
"git fetch origin main",
|
|
59
|
+
]);
|
|
60
|
+
assert.match(lines.join("\n"), /Failed #264: merge failed/);
|
|
61
|
+
assert.match(lines.join("\n"), /Skipped #265: mergeable=CONFLICTING/);
|
|
62
|
+
assert.match(lines.join("\n"), /Merged #266/);
|
|
63
|
+
});
|
|
64
|
+
test("runSprintMerge dry-run validates PR state via gh pr view and prints plan without mutating commands", async () => {
|
|
65
|
+
const mutatingCalls = [];
|
|
66
|
+
const runner = (command, args) => {
|
|
67
|
+
if (command === "gh" && args[0] === "pr" && args[1] === "view") {
|
|
68
|
+
return JSON.stringify({ state: "OPEN", mergeable: "MERGEABLE", reviewDecision: "APPROVED" });
|
|
69
|
+
}
|
|
70
|
+
mutatingCalls.push([command, ...args].join(" "));
|
|
71
|
+
return "";
|
|
72
|
+
};
|
|
73
|
+
const lines = await runSprintMerge([264, 265, 266], { dryRun: true, runner });
|
|
74
|
+
assert.deepEqual(mutatingCalls, [], "dry-run must not invoke any mutating commands");
|
|
75
|
+
assert.deepEqual(lines, [
|
|
76
|
+
"[DRY RUN] Merge order: #264 -> #265 -> #266",
|
|
77
|
+
"[DRY RUN] PR #264: OPEN, mergeable: MERGEABLE, reviewDecision: APPROVED ✅",
|
|
78
|
+
"[DRY RUN] Would merge #264 with: gh pr merge 264 --squash",
|
|
79
|
+
"[DRY RUN] Would fetch origin/main with: git fetch origin main",
|
|
80
|
+
"[DRY RUN] Would refresh #265 with: gh pr update-branch 265",
|
|
81
|
+
"[DRY RUN] Would refresh #266 with: gh pr update-branch 266",
|
|
82
|
+
"[DRY RUN] PR #265: OPEN, mergeable: MERGEABLE, reviewDecision: APPROVED ✅",
|
|
83
|
+
"[DRY RUN] Would merge #265 with: gh pr merge 265 --squash",
|
|
84
|
+
"[DRY RUN] Would fetch origin/main with: git fetch origin main",
|
|
85
|
+
"[DRY RUN] Would refresh #266 with: gh pr update-branch 266",
|
|
86
|
+
"[DRY RUN] PR #266: OPEN, mergeable: MERGEABLE, reviewDecision: APPROVED ✅",
|
|
87
|
+
"[DRY RUN] Would merge #266 with: gh pr merge 266 --squash",
|
|
88
|
+
"[DRY RUN] Would fetch origin/main with: git fetch origin main",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
test("runSprintMerge dry-run flags closed and conflicting PRs but reports full plan", async () => {
|
|
92
|
+
const mutatingCalls = [];
|
|
93
|
+
const runner = (command, args) => {
|
|
94
|
+
if (command === "gh" && args[0] === "pr" && args[1] === "view") {
|
|
95
|
+
const prNumber = args[2];
|
|
96
|
+
if (prNumber === "264") {
|
|
97
|
+
return JSON.stringify({ state: "CLOSED", mergeable: "UNKNOWN", reviewDecision: null });
|
|
98
|
+
}
|
|
99
|
+
if (prNumber === "265") {
|
|
100
|
+
return JSON.stringify({ state: "OPEN", mergeable: "CONFLICTING", reviewDecision: null });
|
|
101
|
+
}
|
|
102
|
+
return JSON.stringify({ state: "OPEN", mergeable: "MERGEABLE", reviewDecision: "APPROVED" });
|
|
103
|
+
}
|
|
104
|
+
mutatingCalls.push([command, ...args].join(" "));
|
|
105
|
+
return "";
|
|
106
|
+
};
|
|
107
|
+
const lines = await runSprintMerge([264, 265, 266], { dryRun: true, runner });
|
|
108
|
+
assert.deepEqual(mutatingCalls, [], "dry-run must not invoke any mutating commands");
|
|
109
|
+
assert.deepEqual(lines, [
|
|
110
|
+
"[DRY RUN] Merge order: #264 -> #265 -> #266",
|
|
111
|
+
"[DRY RUN] PR #264: CLOSED ❌ — would be skipped",
|
|
112
|
+
"[DRY RUN] PR #265: OPEN, mergeable: CONFLICTING ❌ — would be skipped",
|
|
113
|
+
"[DRY RUN] PR #266: OPEN, mergeable: MERGEABLE, reviewDecision: APPROVED ✅",
|
|
114
|
+
"[DRY RUN] Would merge #266 with: gh pr merge 266 --squash",
|
|
115
|
+
"[DRY RUN] Would fetch origin/main with: git fetch origin main",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
test("parseSprintMergeArgs accepts --dry-run, -n, and PR numbers", () => {
|
|
119
|
+
assert.deepEqual(parseSprintMergeArgs(["--dry-run", "264", "265"]), {
|
|
120
|
+
dryRun: true,
|
|
121
|
+
prs: [264, 265],
|
|
122
|
+
});
|
|
123
|
+
assert.deepEqual(parseSprintMergeArgs(["-n", "266"]), {
|
|
124
|
+
dryRun: true,
|
|
125
|
+
prs: [266],
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
test("parseSprintMergeArgs rejects missing PR numbers with dry-run usage example", () => {
|
|
129
|
+
assert.throws(() => parseSprintMergeArgs([]), /Usage:[\s\S]*--dry-run[\s\S]*Examples:[\s\S]*--dry-run 264 265 266/);
|
|
130
|
+
});
|
|
131
|
+
//# sourceMappingURL=sprint-merge.test.js.map
|
package/dist/store/db.js
CHANGED
|
@@ -1094,6 +1094,69 @@ export function getTaskSessionKey(taskId) {
|
|
|
1094
1094
|
return "default";
|
|
1095
1095
|
}
|
|
1096
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Return whether a batch of agent tasks has fully reached terminal completion.
|
|
1099
|
+
*
|
|
1100
|
+
* `completed` and `error` are terminal. Missing rows are treated as not-ready
|
|
1101
|
+
* so callers do not open downstream barriers (for example, a Scribe merge pass)
|
|
1102
|
+
* until every expected task has recorded a terminal state.
|
|
1103
|
+
*/
|
|
1104
|
+
export function getTaskBarrierStatus(taskIds) {
|
|
1105
|
+
const orderedTaskIds = Array.from(new Set(taskIds.filter((taskId) => taskId.trim().length > 0)));
|
|
1106
|
+
if (orderedTaskIds.length === 0) {
|
|
1107
|
+
return {
|
|
1108
|
+
ready: true,
|
|
1109
|
+
pendingTaskIds: [],
|
|
1110
|
+
missingTaskIds: [],
|
|
1111
|
+
statuses: [],
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const db = getDb();
|
|
1115
|
+
const placeholders = orderedTaskIds.map(() => "?").join(", ");
|
|
1116
|
+
const rows = db.prepare(`SELECT task_id, status FROM agent_tasks WHERE task_id IN (${placeholders})`).all(...orderedTaskIds);
|
|
1117
|
+
const statusByTaskId = new Map(rows.map((row) => [row.task_id, row.status ?? "missing"]));
|
|
1118
|
+
const statuses = orderedTaskIds.map((taskId) => ({
|
|
1119
|
+
taskId,
|
|
1120
|
+
status: statusByTaskId.get(taskId) ?? "missing",
|
|
1121
|
+
}));
|
|
1122
|
+
const missingTaskIds = statuses.filter((entry) => entry.status === "missing").map((entry) => entry.taskId);
|
|
1123
|
+
const pendingTaskIds = statuses
|
|
1124
|
+
.filter((entry) => entry.status !== "completed" && entry.status !== "error")
|
|
1125
|
+
.map((entry) => entry.taskId);
|
|
1126
|
+
return {
|
|
1127
|
+
ready: pendingTaskIds.length === 0,
|
|
1128
|
+
pendingTaskIds,
|
|
1129
|
+
missingTaskIds,
|
|
1130
|
+
statuses,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Poll `getTaskBarrierStatus` until all tasks are terminal or the timeout
|
|
1135
|
+
* is reached.
|
|
1136
|
+
*
|
|
1137
|
+
* On VS Code, all subagents in a single coordinator turn run concurrently,
|
|
1138
|
+
* so Scribe cannot rely on siblings being complete at the instant it starts.
|
|
1139
|
+
* This function lets Scribe (or any downstream pass) wait until every
|
|
1140
|
+
* sibling has written its `.squad/decisions/inbox/` file and reached a
|
|
1141
|
+
* terminal state before proceeding with the merge.
|
|
1142
|
+
*
|
|
1143
|
+
* Returns the last observed `TaskBarrierStatus`. Callers must check
|
|
1144
|
+
* `.ready` — a `false` result means the timeout was reached with at least
|
|
1145
|
+
* one task still pending.
|
|
1146
|
+
*/
|
|
1147
|
+
export async function pollTaskBarrierStatus(taskIds, options) {
|
|
1148
|
+
const intervalMs = options?.intervalMs ?? 500;
|
|
1149
|
+
const timeoutMs = options?.timeoutMs ?? 30_000;
|
|
1150
|
+
const deadline = Date.now() + timeoutMs;
|
|
1151
|
+
while (true) {
|
|
1152
|
+
const status = getTaskBarrierStatus(taskIds);
|
|
1153
|
+
if (status.ready)
|
|
1154
|
+
return status;
|
|
1155
|
+
if (Date.now() >= deadline)
|
|
1156
|
+
return status;
|
|
1157
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1097
1160
|
/**
|
|
1098
1161
|
* Get recent conversation history formatted for injection into system message.
|
|
1099
1162
|
*
|
package/dist/store/db.test.js
CHANGED
|
@@ -851,6 +851,285 @@ test("#158: updateTaskResult updates agent_tasks status and result", async () =>
|
|
|
851
851
|
dbModule.closeDb();
|
|
852
852
|
}
|
|
853
853
|
});
|
|
854
|
+
test("#275: getTaskBarrierStatus returns complete when every expected task is terminal", async () => {
|
|
855
|
+
const dbModule = await loadDbModule();
|
|
856
|
+
try {
|
|
857
|
+
const db = dbModule.getDb();
|
|
858
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-001", "coder", "completed work", "completed");
|
|
859
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-002", "oracle", "failed but terminal", "error");
|
|
860
|
+
assert.deepEqual(dbModule.getTaskBarrierStatus(["task-gate-001", "task-gate-002"]), {
|
|
861
|
+
ready: true,
|
|
862
|
+
pendingTaskIds: [],
|
|
863
|
+
missingTaskIds: [],
|
|
864
|
+
statuses: [
|
|
865
|
+
{ taskId: "task-gate-001", status: "completed" },
|
|
866
|
+
{ taskId: "task-gate-002", status: "error" },
|
|
867
|
+
],
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
dbModule.closeDb();
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
test("#275: getTaskBarrierStatus returns pending when expected tasks are missing", async () => {
|
|
875
|
+
const dbModule = await loadDbModule();
|
|
876
|
+
try {
|
|
877
|
+
const db = dbModule.getDb();
|
|
878
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-003", "coder", "still running", "running");
|
|
879
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-004", "oracle", "already done", "completed");
|
|
880
|
+
assert.deepEqual(dbModule.getTaskBarrierStatus(["task-gate-003", "task-gate-004", "task-gate-005", "task-gate-006"]), {
|
|
881
|
+
ready: false,
|
|
882
|
+
pendingTaskIds: ["task-gate-003", "task-gate-005", "task-gate-006"],
|
|
883
|
+
missingTaskIds: ["task-gate-005", "task-gate-006"],
|
|
884
|
+
statuses: [
|
|
885
|
+
{ taskId: "task-gate-003", status: "running" },
|
|
886
|
+
{ taskId: "task-gate-004", status: "completed" },
|
|
887
|
+
{ taskId: "task-gate-005", status: "missing" },
|
|
888
|
+
{ taskId: "task-gate-006", status: "missing" },
|
|
889
|
+
],
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
dbModule.closeDb();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
test("#275: getTaskBarrierStatus keeps gate closed when one task is still running", async () => {
|
|
897
|
+
const dbModule = await loadDbModule();
|
|
898
|
+
try {
|
|
899
|
+
const db = dbModule.getDb();
|
|
900
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-007", "coder", "finished work", "completed");
|
|
901
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-gate-008", "oracle", "still in progress", "running");
|
|
902
|
+
const result = dbModule.getTaskBarrierStatus(["task-gate-007", "task-gate-008"]);
|
|
903
|
+
assert.strictEqual(result.ready, false, "barrier must not be ready while a task is still running");
|
|
904
|
+
assert.deepEqual(result.pendingTaskIds, ["task-gate-008"]);
|
|
905
|
+
assert.deepEqual(result.missingTaskIds, []);
|
|
906
|
+
assert.deepEqual(result.statuses, [
|
|
907
|
+
{ taskId: "task-gate-007", status: "completed" },
|
|
908
|
+
{ taskId: "task-gate-008", status: "running" },
|
|
909
|
+
]);
|
|
910
|
+
}
|
|
911
|
+
finally {
|
|
912
|
+
dbModule.closeDb();
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
test("#275: getTaskBarrierStatus returns complete immediately when no tasks are expected", async () => {
|
|
916
|
+
const dbModule = await loadDbModule();
|
|
917
|
+
try {
|
|
918
|
+
assert.deepEqual(dbModule.getTaskBarrierStatus([]), {
|
|
919
|
+
ready: true,
|
|
920
|
+
pendingTaskIds: [],
|
|
921
|
+
missingTaskIds: [],
|
|
922
|
+
statuses: [],
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
dbModule.closeDb();
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
// pollTaskBarrierStatus — VS Code concurrent-spawn race regression tests (#275)
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
test("#275 vscode: pollTaskBarrierStatus resolves immediately when all tasks are already terminal", async () => {
|
|
933
|
+
const dbModule = await loadDbModule();
|
|
934
|
+
try {
|
|
935
|
+
const db = dbModule.getDb();
|
|
936
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-001", "coder", "already done", "completed");
|
|
937
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-002", "oracle", "terminal error", "error");
|
|
938
|
+
const start = Date.now();
|
|
939
|
+
const result = await dbModule.pollTaskBarrierStatus(["task-poll-001", "task-poll-002"], { intervalMs: 50, timeoutMs: 2000 });
|
|
940
|
+
const elapsed = Date.now() - start;
|
|
941
|
+
assert.deepEqual(result, {
|
|
942
|
+
ready: true,
|
|
943
|
+
pendingTaskIds: [],
|
|
944
|
+
missingTaskIds: [],
|
|
945
|
+
statuses: [
|
|
946
|
+
{ taskId: "task-poll-001", status: "completed" },
|
|
947
|
+
{ taskId: "task-poll-002", status: "error" },
|
|
948
|
+
],
|
|
949
|
+
});
|
|
950
|
+
assert.ok(elapsed < 500, `should resolve quickly, took ${elapsed}ms`);
|
|
951
|
+
}
|
|
952
|
+
finally {
|
|
953
|
+
dbModule.closeDb();
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
test("#275 vscode: pollTaskBarrierStatus retries until a missing sibling record arrives", async () => {
|
|
957
|
+
const dbModule = await loadDbModule();
|
|
958
|
+
try {
|
|
959
|
+
const db = dbModule.getDb();
|
|
960
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-003", "coder", "finished early", "completed");
|
|
961
|
+
// Insert 3× the poll interval after start to give comfortable CI margin
|
|
962
|
+
setTimeout(() => {
|
|
963
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-004", "neo", "late arrival", "completed");
|
|
964
|
+
}, 150);
|
|
965
|
+
const start = Date.now();
|
|
966
|
+
const result = await dbModule.pollTaskBarrierStatus(["task-poll-003", "task-poll-004"], { intervalMs: 50, timeoutMs: 3000 });
|
|
967
|
+
const elapsed = Date.now() - start;
|
|
968
|
+
assert.deepEqual(result, {
|
|
969
|
+
ready: true,
|
|
970
|
+
pendingTaskIds: [],
|
|
971
|
+
missingTaskIds: [],
|
|
972
|
+
statuses: [
|
|
973
|
+
{ taskId: "task-poll-003", status: "completed" },
|
|
974
|
+
{ taskId: "task-poll-004", status: "completed" },
|
|
975
|
+
],
|
|
976
|
+
});
|
|
977
|
+
assert.ok(elapsed >= 150, `should wait for late-arriving task, took ${elapsed}ms`);
|
|
978
|
+
}
|
|
979
|
+
finally {
|
|
980
|
+
dbModule.closeDb();
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
test("#275 vscode: pollTaskBarrierStatus returns timeout when a sibling never reaches a terminal state", async () => {
|
|
984
|
+
const dbModule = await loadDbModule();
|
|
985
|
+
try {
|
|
986
|
+
const db = dbModule.getDb();
|
|
987
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-005", "coder", "finished", "completed");
|
|
988
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-006", "neo", "never finishes", "running");
|
|
989
|
+
const start = Date.now();
|
|
990
|
+
const result = await dbModule.pollTaskBarrierStatus(["task-poll-005", "task-poll-006"], { intervalMs: 50, timeoutMs: 300 });
|
|
991
|
+
const elapsed = Date.now() - start;
|
|
992
|
+
assert.deepEqual(result, {
|
|
993
|
+
ready: false,
|
|
994
|
+
pendingTaskIds: ["task-poll-006"],
|
|
995
|
+
missingTaskIds: [],
|
|
996
|
+
statuses: [
|
|
997
|
+
{ taskId: "task-poll-005", status: "completed" },
|
|
998
|
+
{ taskId: "task-poll-006", status: "running" },
|
|
999
|
+
],
|
|
1000
|
+
});
|
|
1001
|
+
assert.ok(elapsed >= 280, `should have waited for ~300ms timeout, took ${elapsed}ms`);
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
dbModule.closeDb();
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
test("#275 vscode: pollTaskBarrierStatus resolves immediately when no tasks are expected", async () => {
|
|
1008
|
+
const dbModule = await loadDbModule();
|
|
1009
|
+
try {
|
|
1010
|
+
const start = Date.now();
|
|
1011
|
+
const result = await dbModule.pollTaskBarrierStatus([], { intervalMs: 50, timeoutMs: 300 });
|
|
1012
|
+
const elapsed = Date.now() - start;
|
|
1013
|
+
assert.deepEqual(result, {
|
|
1014
|
+
ready: true,
|
|
1015
|
+
pendingTaskIds: [],
|
|
1016
|
+
missingTaskIds: [],
|
|
1017
|
+
statuses: [],
|
|
1018
|
+
});
|
|
1019
|
+
assert.ok(elapsed < 100, `empty barrier should resolve immediately, took ${elapsed}ms`);
|
|
1020
|
+
}
|
|
1021
|
+
finally {
|
|
1022
|
+
dbModule.closeDb();
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
test("#275 vscode: pollTaskBarrierStatus returns partial completion details when timeout leaves one task missing", async () => {
|
|
1026
|
+
const dbModule = await loadDbModule();
|
|
1027
|
+
try {
|
|
1028
|
+
const db = dbModule.getDb();
|
|
1029
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-007", "coder", "already finished", "completed");
|
|
1030
|
+
// Insert 3× the poll interval after start to give comfortable CI margin
|
|
1031
|
+
setTimeout(() => {
|
|
1032
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-poll-008", "oracle", "finished during polling", "completed");
|
|
1033
|
+
}, 150);
|
|
1034
|
+
const start = Date.now();
|
|
1035
|
+
const result = await dbModule.pollTaskBarrierStatus(["task-poll-007", "task-poll-008", "task-poll-009"], {
|
|
1036
|
+
intervalMs: 50,
|
|
1037
|
+
timeoutMs: 400,
|
|
1038
|
+
});
|
|
1039
|
+
const elapsed = Date.now() - start;
|
|
1040
|
+
assert.deepEqual(result, {
|
|
1041
|
+
ready: false,
|
|
1042
|
+
pendingTaskIds: ["task-poll-009"],
|
|
1043
|
+
missingTaskIds: ["task-poll-009"],
|
|
1044
|
+
statuses: [
|
|
1045
|
+
{ taskId: "task-poll-007", status: "completed" },
|
|
1046
|
+
{ taskId: "task-poll-008", status: "completed" },
|
|
1047
|
+
{ taskId: "task-poll-009", status: "missing" },
|
|
1048
|
+
],
|
|
1049
|
+
});
|
|
1050
|
+
assert.ok(elapsed >= 350, `partial timeout should wait until deadline, took ${elapsed}ms`);
|
|
1051
|
+
}
|
|
1052
|
+
finally {
|
|
1053
|
+
dbModule.closeDb();
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
// Scribe sequencing contract — VS Code coordinator (#275 / #289)
|
|
1058
|
+
//
|
|
1059
|
+
// Regression guard: the Coordinator MUST spawn Scribe in a *separate subsequent
|
|
1060
|
+
// turn*, not batched alongside sibling agents. If Scribe were batched in the
|
|
1061
|
+
// same parallel group, it could start its merge pass before siblings have
|
|
1062
|
+
// written their .squad/decisions/inbox/ files, reproducing the original #283
|
|
1063
|
+
// race condition.
|
|
1064
|
+
//
|
|
1065
|
+
// The invariant enforced here:
|
|
1066
|
+
// pollTaskBarrierStatus(siblingIds) resolves ready=true
|
|
1067
|
+
// BEFORE any code that schedules Scribe can execute.
|
|
1068
|
+
//
|
|
1069
|
+
// These tests use the real running→completed UPDATE lifecycle (not final-state
|
|
1070
|
+
// INSERTs) to exercise the same code path the orchestrator follows.
|
|
1071
|
+
// ---------------------------------------------------------------------------
|
|
1072
|
+
test("#275 vscode: Scribe sequencing contract — barrier resolves before Scribe is schedulable", async () => {
|
|
1073
|
+
const dbModule = await loadDbModule();
|
|
1074
|
+
try {
|
|
1075
|
+
const db = dbModule.getDb();
|
|
1076
|
+
// Coordinator spawns sibling agents — both start as "running" (real lifecycle)
|
|
1077
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-seq-001", "coder", "sibling work", "running");
|
|
1078
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-seq-002", "oracle", "sibling review", "running");
|
|
1079
|
+
const sequenceLog = [];
|
|
1080
|
+
// Sibling agents complete asynchronously (3× poll interval for CI safety)
|
|
1081
|
+
setTimeout(() => {
|
|
1082
|
+
db.prepare("UPDATE agent_tasks SET status = 'completed' WHERE task_id = ?").run("task-seq-001");
|
|
1083
|
+
db.prepare("UPDATE agent_tasks SET status = 'completed' WHERE task_id = ?").run("task-seq-002");
|
|
1084
|
+
sequenceLog.push("siblings-terminal");
|
|
1085
|
+
}, 150);
|
|
1086
|
+
// Coordinator must await the barrier — nothing after this line runs until siblings are terminal
|
|
1087
|
+
const barrierResult = await dbModule.pollTaskBarrierStatus(["task-seq-001", "task-seq-002"], { intervalMs: 50, timeoutMs: 3000 });
|
|
1088
|
+
sequenceLog.push("barrier-resolved");
|
|
1089
|
+
// Only schedule Scribe when barrier confirms all siblings are terminal
|
|
1090
|
+
if (barrierResult.ready) {
|
|
1091
|
+
sequenceLog.push("scribe-spawned");
|
|
1092
|
+
}
|
|
1093
|
+
// Core sequencing invariant: siblings-terminal → barrier-resolved → scribe-spawned
|
|
1094
|
+
assert.ok(sequenceLog.indexOf("siblings-terminal") < sequenceLog.indexOf("barrier-resolved"), "barrier must not resolve before siblings reach a terminal state");
|
|
1095
|
+
assert.ok(sequenceLog.indexOf("barrier-resolved") < sequenceLog.indexOf("scribe-spawned"), "Scribe must not be spawned before the barrier resolves");
|
|
1096
|
+
assert.strictEqual(barrierResult.ready, true, "barrier must be ready when all siblings are terminal");
|
|
1097
|
+
assert.deepEqual(barrierResult.pendingTaskIds, []);
|
|
1098
|
+
assert.deepEqual(barrierResult.statuses, [
|
|
1099
|
+
{ taskId: "task-seq-001", status: "completed" },
|
|
1100
|
+
{ taskId: "task-seq-002", status: "completed" },
|
|
1101
|
+
]);
|
|
1102
|
+
}
|
|
1103
|
+
finally {
|
|
1104
|
+
dbModule.closeDb();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
test("#275 vscode: premature Scribe dispatch is blocked when a sibling is still running", async () => {
|
|
1108
|
+
// This test encodes the negative case: a coordinator that checks the barrier
|
|
1109
|
+
// synchronously (or skips the await) and tries to schedule Scribe immediately
|
|
1110
|
+
// must be blocked. If this test were removed and the "batched" pattern were
|
|
1111
|
+
// reintroduced, getTaskBarrierStatus would still return ready=false here,
|
|
1112
|
+
// causing this test to fail and alerting that the gate was removed.
|
|
1113
|
+
const dbModule = await loadDbModule();
|
|
1114
|
+
try {
|
|
1115
|
+
const db = dbModule.getDb();
|
|
1116
|
+
// One sibling finished, one still running — exact pre-race state from #283
|
|
1117
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-preempt-001", "coder", "finished first", "completed");
|
|
1118
|
+
db.prepare("INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES (?, ?, ?, ?)").run("task-preempt-002", "oracle", "still running", "running");
|
|
1119
|
+
// Synchronous barrier check — as if coordinator did NOT await
|
|
1120
|
+
const immediateStatus = dbModule.getTaskBarrierStatus(["task-preempt-001", "task-preempt-002"]);
|
|
1121
|
+
// Gate is closed: Scribe must not be scheduled
|
|
1122
|
+
assert.strictEqual(immediateStatus.ready, false, "barrier must block Scribe while any sibling is still running");
|
|
1123
|
+
assert.deepEqual(immediateStatus.pendingTaskIds, ["task-preempt-002"]);
|
|
1124
|
+
assert.deepEqual(immediateStatus.missingTaskIds, []);
|
|
1125
|
+
// Coordinator guard: Scribe is only schedulable when the gate opens
|
|
1126
|
+
const scribeSchedulable = immediateStatus.ready;
|
|
1127
|
+
assert.strictEqual(scribeSchedulable, false, "Scribe scheduling must be gated on barrier readiness");
|
|
1128
|
+
}
|
|
1129
|
+
finally {
|
|
1130
|
+
dbModule.closeDb();
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
854
1133
|
// ---------------------------------------------------------------------------
|
|
855
1134
|
// normalizeSqliteTsToIso — unit tests
|
|
856
1135
|
// ---------------------------------------------------------------------------
|
package/dist/wiki/team-sync.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { config } from "../config.js";
|
|
4
|
+
import { ModeContext } from "../mode-context.js";
|
|
4
5
|
import { WIKI_DIR } from "../paths.js";
|
|
5
6
|
import { assertPagePath, readPage, writePage, writeFileAtomic } from "./fs.js";
|
|
6
7
|
import { addToIndex, buildIndexEntryForPage } from "./index-manager.js";
|
|
@@ -18,6 +19,7 @@ export class TeamWikiSync {
|
|
|
18
19
|
fetchImpl;
|
|
19
20
|
warn;
|
|
20
21
|
now;
|
|
22
|
+
modeContext;
|
|
21
23
|
constructor(options = {}) {
|
|
22
24
|
this.teamChapterhouseUrl = (options.teamChapterhouseUrl ?? config.teamChapterhouseUrl).trim().replace(/\/+$/, "");
|
|
23
25
|
this.teamChapterhouseToken = (options.teamChapterhouseToken ?? config.teamChapterhouseToken).trim();
|
|
@@ -32,9 +34,14 @@ export class TeamWikiSync {
|
|
|
32
34
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
33
35
|
this.warn = options.warn ?? ((message) => log.warn(message));
|
|
34
36
|
this.now = options.now ?? (() => new Date());
|
|
37
|
+
this.modeContext = new ModeContext({
|
|
38
|
+
...config,
|
|
39
|
+
teamChapterhouseUrl: this.teamChapterhouseUrl,
|
|
40
|
+
standaloneMode: this.standaloneMode,
|
|
41
|
+
});
|
|
35
42
|
}
|
|
36
43
|
isEnabled() {
|
|
37
|
-
return
|
|
44
|
+
return this.modeContext.canSyncTeamWiki();
|
|
38
45
|
}
|
|
39
46
|
isTeamPath(path) {
|
|
40
47
|
return this.teamWikiPaths.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chapterhouse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"chapterhouse": "dist/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"dist/**/*.js",
|
|
10
|
+
".pr-types.json",
|
|
10
11
|
"agents/",
|
|
11
12
|
"skills/",
|
|
12
13
|
"web/dist/",
|
|
@@ -22,7 +23,10 @@
|
|
|
22
23
|
"dev:server": "tsx --watch src/daemon.ts",
|
|
23
24
|
"dev:web": "npm --prefix web run dev",
|
|
24
25
|
"dev": "tsx --watch src/daemon.ts",
|
|
26
|
+
"sprint:merge": "tsx src/sprint-merge.ts",
|
|
25
27
|
"lint:md": "markdownlint-cli2 'README.md' 'CHANGELOG.md' 'docs/**/*.md' '.github/**/*.md'",
|
|
28
|
+
"pr:title:check": "tsx scripts/validate-pr-title.ts",
|
|
29
|
+
"threat-model:check": "tsx scripts/check-threat-model.ts",
|
|
26
30
|
"release:check": "if [ -n \"$(git status --porcelain)\" ]; then echo '❌ Working tree is not clean. Stage or stash changes before running npm version.'; git status --short; exit 1; fi",
|
|
27
31
|
"preversion": "npm run release:check",
|
|
28
32
|
"prepare": "husky",
|
|
@@ -73,6 +77,7 @@
|
|
|
73
77
|
"@types/node": "^25.6.0",
|
|
74
78
|
"husky": "^9.1.7",
|
|
75
79
|
"markdownlint-cli2": "^0.22.1",
|
|
80
|
+
"ts-node": "^10.9.2",
|
|
76
81
|
"tsx": "^4.21.0",
|
|
77
82
|
"typescript": "^5.9.3"
|
|
78
83
|
}
|