chapterhouse 0.3.20 → 0.3.22
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/api/server.js +154 -3
- package/dist/api/server.test.js +461 -14
- package/dist/copilot/orchestrator.js +70 -3
- package/dist/copilot/orchestrator.test.js +337 -1
- package/dist/copilot/project-resolution.js +73 -0
- package/dist/copilot/project-resolution.test.js +124 -0
- package/dist/copilot/project-rule-warnings.js +73 -0
- package/dist/copilot/project-rule-warnings.test.js +46 -0
- package/dist/copilot/project-rules-injection.js +71 -0
- package/dist/copilot/project-rules-injection.test.js +84 -0
- package/dist/copilot/tools.agent.test.js +214 -0
- package/dist/copilot/tools.js +14 -3
- package/dist/store/db.js +4 -0
- package/dist/store/db.test.js +30 -0
- package/dist/wiki/frontmatter.js +1 -1
- package/dist/wiki/lint.js +37 -10
- package/dist/wiki/lint.test.js +72 -0
- package/dist/wiki/project-registry.js +160 -0
- package/dist/wiki/project-registry.test.js +72 -0
- package/dist/wiki/project-rules.js +155 -0
- package/dist/wiki/project-rules.test.js +217 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-9We9vWBC.js → index-Ch4AYrQP.js} +72 -69
- package/web/dist/assets/index-Ch4AYrQP.js.map +1 -0
- package/web/dist/assets/index-D__tBB0X.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-9We9vWBC.js.map +0 -1
- package/web/dist/assets/index-DYx2idiH.css +0 -10
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
async function loadModule() {
|
|
4
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
5
|
+
return import(new URL(`./project-resolution.js?case=${nonce}`, import.meta.url).href);
|
|
6
|
+
}
|
|
7
|
+
test("extractProjectMention returns the first exact slug mention", async () => {
|
|
8
|
+
const { extractProjectMention } = await loadModule();
|
|
9
|
+
assert.equal(extractProjectMention("Please fix this for (@project:docs-site), then circle back to @project:chapterhouse."), "docs-site");
|
|
10
|
+
});
|
|
11
|
+
test("extractProjectMention ignores partial, cased, and embedded mentions", async () => {
|
|
12
|
+
const { extractProjectMention } = await loadModule();
|
|
13
|
+
assert.equal(extractProjectMention("email foo@project:docs-site later"), null);
|
|
14
|
+
assert.equal(extractProjectMention("@project:Docs-Site fix this"), null);
|
|
15
|
+
assert.equal(extractProjectMention("@project:docs_site fix this"), null);
|
|
16
|
+
});
|
|
17
|
+
test("resolveProject prefers an explicit project mention over path-based matches", async () => {
|
|
18
|
+
const { resolveProject } = await loadModule();
|
|
19
|
+
const registry = {
|
|
20
|
+
chapterhouse: "/work/chapterhouse",
|
|
21
|
+
"docs-site": "/work/docs-site",
|
|
22
|
+
};
|
|
23
|
+
assert.deepEqual(resolveProject("@project:chapterhouse fix the spinner", {
|
|
24
|
+
selectedProjectPath: "/work/docs-site",
|
|
25
|
+
cwd: "/work/docs-site",
|
|
26
|
+
}, registry), {
|
|
27
|
+
slug: "chapterhouse",
|
|
28
|
+
path: "/work/chapterhouse",
|
|
29
|
+
source: "mention",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
test("resolveProject falls back to the selected project path when an explicit mention is not registered", async () => {
|
|
33
|
+
const { resolveProject } = await loadModule();
|
|
34
|
+
const registry = {
|
|
35
|
+
chapterhouse: "/work/chapterhouse",
|
|
36
|
+
"docs-site": "/work/docs-site",
|
|
37
|
+
};
|
|
38
|
+
assert.deepEqual(resolveProject("@project:missing update the landing page", {
|
|
39
|
+
selectedProjectPath: "/work/docs-site",
|
|
40
|
+
cwd: "/work/chapterhouse",
|
|
41
|
+
}, registry), {
|
|
42
|
+
slug: "docs-site",
|
|
43
|
+
path: "/work/docs-site",
|
|
44
|
+
source: "selectedProjectPath",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
test("resolveProject treats projectPath as the selected project path signal", async () => {
|
|
48
|
+
const { resolveProject } = await loadModule();
|
|
49
|
+
const registry = {
|
|
50
|
+
chapterhouse: "/work/chapterhouse",
|
|
51
|
+
"docs-site": "/work/docs-site",
|
|
52
|
+
};
|
|
53
|
+
assert.deepEqual(resolveProject("Update the landing page", {
|
|
54
|
+
projectPath: "/work/docs-site",
|
|
55
|
+
cwd: "/work/chapterhouse",
|
|
56
|
+
}, registry), {
|
|
57
|
+
slug: "docs-site",
|
|
58
|
+
path: "/work/docs-site",
|
|
59
|
+
source: "selectedProjectPath",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
test("resolveProject matches an exact selected project path before cwd", async () => {
|
|
63
|
+
const { resolveProject } = await loadModule();
|
|
64
|
+
const registry = {
|
|
65
|
+
chapterhouse: "/work/chapterhouse",
|
|
66
|
+
"docs-site": "/work/docs-site",
|
|
67
|
+
};
|
|
68
|
+
assert.deepEqual(resolveProject("Update the landing page", {
|
|
69
|
+
selectedProjectPath: "/work/docs-site",
|
|
70
|
+
cwd: "/work/chapterhouse",
|
|
71
|
+
}, registry), {
|
|
72
|
+
slug: "docs-site",
|
|
73
|
+
path: "/work/docs-site",
|
|
74
|
+
source: "selectedProjectPath",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
test("resolveProject matches descendant paths and picks the longest registered prefix", async () => {
|
|
78
|
+
const { resolveProject } = await loadModule();
|
|
79
|
+
const registry = {
|
|
80
|
+
monorepo: "/work/chapterhouse",
|
|
81
|
+
web: "/work/chapterhouse/web",
|
|
82
|
+
docs: "/work/docs",
|
|
83
|
+
};
|
|
84
|
+
assert.deepEqual(resolveProject("Fix the UI build", {
|
|
85
|
+
cwd: "/work/chapterhouse/web/src/routes",
|
|
86
|
+
}, registry), {
|
|
87
|
+
slug: "web",
|
|
88
|
+
path: "/work/chapterhouse/web",
|
|
89
|
+
source: "cwd",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test("resolveProject does not treat sibling prefixes as descendant matches", async () => {
|
|
93
|
+
const { resolveProject } = await loadModule();
|
|
94
|
+
const registry = {
|
|
95
|
+
app: "/work/app",
|
|
96
|
+
};
|
|
97
|
+
assert.equal(resolveProject("Fix it", {
|
|
98
|
+
cwd: "/work/app2/src",
|
|
99
|
+
}, registry), null);
|
|
100
|
+
});
|
|
101
|
+
test("resolveProject trims trailing slashes before path matching", async () => {
|
|
102
|
+
const { resolveProject } = await loadModule();
|
|
103
|
+
const registry = {
|
|
104
|
+
chapterhouse: "/work/chapterhouse/",
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(resolveProject("Fix it", {
|
|
107
|
+
cwd: "/work/chapterhouse/web/",
|
|
108
|
+
}, registry), {
|
|
109
|
+
slug: "chapterhouse",
|
|
110
|
+
path: "/work/chapterhouse",
|
|
111
|
+
source: "cwd",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
test("resolveProject returns null when neither message text nor paths resolve to a registered project", async () => {
|
|
115
|
+
const { resolveProject } = await loadModule();
|
|
116
|
+
const registry = {
|
|
117
|
+
chapterhouse: "/work/chapterhouse",
|
|
118
|
+
};
|
|
119
|
+
assert.equal(resolveProject("Fix it", {
|
|
120
|
+
selectedProjectPath: "/work/docs-site",
|
|
121
|
+
cwd: "/tmp/other",
|
|
122
|
+
}, registry), null);
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=project-resolution.test.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const WARNING_CAPABLE_FIELDS = [
|
|
2
|
+
"auto_pr",
|
|
3
|
+
"require_worktree",
|
|
4
|
+
"pr_draft_default",
|
|
5
|
+
"default_branch",
|
|
6
|
+
"require_clean_worktree",
|
|
7
|
+
];
|
|
8
|
+
const PR_CREATION_RE = /\b(?:open|create|raise|submit|start)\s+(?:a\s+|the\s+)?(?:(?:non[-\s]?draft|draft|ready[-\s]+for[-\s]+review)\s+)?(?:pull request|pr)\b/i;
|
|
9
|
+
const PR_NEGATION_RE = /\b(?:do not|don't|dont|not|avoid)\s+(?:open|create|raise|submit|start)\s+(?:a\s+|the\s+)?(?:pull request|pr)\b/i;
|
|
10
|
+
const NON_DRAFT_PR_RE = /\b(?:non[-\s]?draft|ready[-\s]+for[-\s]+review)\s+(?:pull request|pr)\b|\b(?:pull request|pr)\b[^.\n]{0,40}\b(?:ready[-\s]+for[-\s]+review|non[-\s]?draft)\b/i;
|
|
11
|
+
const MAIN_CHECKOUT_RE = /\b(?:directly\s+in|from|using|on)\s+the\s+main\s+checkout\b|\bwithout\s+(?:a\s+)?worktree\b|\bskip\s+the\s+worktree\b/i;
|
|
12
|
+
const DIRTY_WORKTREE_RE = /\bdirty\s+(?:worktree|tree)\b|\buncommitted changes\b|\bunclean\s+(?:worktree|tree)\b/i;
|
|
13
|
+
const RISKY_OPERATION_RE = /\b(?:release|ship|publish|deploy|tag|version|bump version|cut a release)\b/i;
|
|
14
|
+
const BRANCH_TARGET_RE = /\b(?:pull request|pr)\b[^.\n]{0,60}\b(?:against|into|onto|target(?:ing)?|base(?:d)? on)\s+([a-z0-9._/-]+)\b/gi;
|
|
15
|
+
const BRANCH_SOURCE_RE = /\b(?:branch|rebase|merge)\b[^.\n]{0,60}\b(?:from|against|into|onto)\s+([a-z0-9._/-]+)\b/gi;
|
|
16
|
+
export function detectProjectRuleWarnings(taskText, hardRules) {
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const normalizedTask = normalize(taskText);
|
|
19
|
+
for (const field of WARNING_CAPABLE_FIELDS) {
|
|
20
|
+
if (!isRuleViolated(field, normalizedTask, hardRules)) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
warnings.push(formatProjectRuleWarning(field, hardRules[field]));
|
|
24
|
+
}
|
|
25
|
+
return warnings;
|
|
26
|
+
}
|
|
27
|
+
function isRuleViolated(field, normalizedTask, hardRules) {
|
|
28
|
+
switch (field) {
|
|
29
|
+
case "auto_pr":
|
|
30
|
+
return hardRules.auto_pr === false
|
|
31
|
+
&& PR_CREATION_RE.test(normalizedTask)
|
|
32
|
+
&& !PR_NEGATION_RE.test(normalizedTask);
|
|
33
|
+
case "require_worktree":
|
|
34
|
+
return hardRules.require_worktree === true && MAIN_CHECKOUT_RE.test(normalizedTask);
|
|
35
|
+
case "pr_draft_default":
|
|
36
|
+
return hardRules.pr_draft_default === true && NON_DRAFT_PR_RE.test(normalizedTask);
|
|
37
|
+
case "default_branch":
|
|
38
|
+
return hasNonDefaultBranchTarget(normalizedTask, hardRules.default_branch);
|
|
39
|
+
case "require_clean_worktree":
|
|
40
|
+
return hardRules.require_clean_worktree === true
|
|
41
|
+
&& DIRTY_WORKTREE_RE.test(normalizedTask)
|
|
42
|
+
&& RISKY_OPERATION_RE.test(normalizedTask);
|
|
43
|
+
default:
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function hasNonDefaultBranchTarget(taskText, defaultBranch) {
|
|
48
|
+
const normalizedDefault = defaultBranch.trim().toLowerCase();
|
|
49
|
+
if (!normalizedDefault) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return extractExplicitBranches(taskText).some((branch) => branch !== normalizedDefault);
|
|
53
|
+
}
|
|
54
|
+
function extractExplicitBranches(taskText) {
|
|
55
|
+
const branches = new Set();
|
|
56
|
+
for (const regex of [BRANCH_TARGET_RE, BRANCH_SOURCE_RE]) {
|
|
57
|
+
regex.lastIndex = 0;
|
|
58
|
+
for (const match of taskText.matchAll(regex)) {
|
|
59
|
+
const branch = match[1]?.trim().toLowerCase();
|
|
60
|
+
if (branch) {
|
|
61
|
+
branches.add(branch);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...branches];
|
|
66
|
+
}
|
|
67
|
+
function formatProjectRuleWarning(field, value) {
|
|
68
|
+
return `⚠️ Project rule warning: this task may violate \`${field}: ${String(value)}\` — proceeding anyway.`;
|
|
69
|
+
}
|
|
70
|
+
function normalize(taskText) {
|
|
71
|
+
return taskText.toLowerCase().replace(/\s+/g, " ").trim();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=project-rule-warnings.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
function createHardRules(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
auto_pr: false,
|
|
6
|
+
require_worktree: true,
|
|
7
|
+
pr_draft_default: true,
|
|
8
|
+
default_branch: "main",
|
|
9
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
10
|
+
test_command: "npm test",
|
|
11
|
+
build_command: "npm run build",
|
|
12
|
+
lint_command: "npm run lint:md",
|
|
13
|
+
require_clean_worktree: true,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function loadModule() {
|
|
18
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
19
|
+
return import(new URL(`./project-rule-warnings.js?case=${nonce}`, import.meta.url).href);
|
|
20
|
+
}
|
|
21
|
+
test("detectProjectRuleWarnings returns ordered warnings for explicit hard-rule conflicts", async () => {
|
|
22
|
+
const module = await loadModule();
|
|
23
|
+
assert.deepEqual(module.detectProjectRuleWarnings("Start the refactor directly in the main checkout, open a non-draft PR against develop, and release from the dirty worktree.", createHardRules()), [
|
|
24
|
+
"⚠️ Project rule warning: this task may violate `auto_pr: false` — proceeding anyway.",
|
|
25
|
+
"⚠️ Project rule warning: this task may violate `require_worktree: true` — proceeding anyway.",
|
|
26
|
+
"⚠️ Project rule warning: this task may violate `pr_draft_default: true` — proceeding anyway.",
|
|
27
|
+
"⚠️ Project rule warning: this task may violate `default_branch: main` — proceeding anyway.",
|
|
28
|
+
"⚠️ Project rule warning: this task may violate `require_clean_worktree: true` — proceeding anyway.",
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
test("detectProjectRuleWarnings warns for an explicit non-default branch target only", async () => {
|
|
32
|
+
const module = await loadModule();
|
|
33
|
+
assert.deepEqual(module.detectProjectRuleWarnings("Create the release branch from develop and target the PR at develop.", createHardRules({
|
|
34
|
+
auto_pr: true,
|
|
35
|
+
pr_draft_default: false,
|
|
36
|
+
require_worktree: false,
|
|
37
|
+
require_clean_worktree: false,
|
|
38
|
+
})), [
|
|
39
|
+
"⚠️ Project rule warning: this task may violate `default_branch: main` — proceeding anyway.",
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
test("detectProjectRuleWarnings stays quiet for compliant or ambiguous wording", async () => {
|
|
43
|
+
const module = await loadModule();
|
|
44
|
+
assert.deepEqual(module.detectProjectRuleWarnings("Do not open a PR yet. If you change code, use a fresh worktree on main and confirm the tree is clean before any release step.", createHardRules()), []);
|
|
45
|
+
});
|
|
46
|
+
//# sourceMappingURL=project-rule-warnings.test.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { loadRegistry } from "../wiki/project-registry.js";
|
|
2
|
+
import { loadProjectRules, } from "../wiki/project-rules.js";
|
|
3
|
+
import { resolveProject } from "./project-resolution.js";
|
|
4
|
+
const PROJECT_RULE_HARD_FIELD_ORDER = [
|
|
5
|
+
"auto_pr",
|
|
6
|
+
"require_worktree",
|
|
7
|
+
"pr_draft_default",
|
|
8
|
+
"default_branch",
|
|
9
|
+
"commit_co_author",
|
|
10
|
+
"test_command",
|
|
11
|
+
"build_command",
|
|
12
|
+
"lint_command",
|
|
13
|
+
"require_clean_worktree",
|
|
14
|
+
];
|
|
15
|
+
export class ActiveProjectRulesLoadError extends Error {
|
|
16
|
+
slug;
|
|
17
|
+
constructor(slug, cause) {
|
|
18
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
19
|
+
super(message);
|
|
20
|
+
this.slug = slug;
|
|
21
|
+
this.name = "ActiveProjectRulesLoadError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function resolveActiveProjectRules(prompt, projectPath, options) {
|
|
25
|
+
const registry = (options?.loadRegistry ?? loadRegistry)();
|
|
26
|
+
const project = resolveProject(prompt, { projectPath }, registry);
|
|
27
|
+
if (!project) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
let rules;
|
|
31
|
+
try {
|
|
32
|
+
rules = (options?.loadProjectRules ?? loadProjectRules)(project.slug);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new ActiveProjectRulesLoadError(project.slug, error);
|
|
36
|
+
}
|
|
37
|
+
if (!rules.found) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
project,
|
|
42
|
+
rules,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function renderActiveProjectRulesBlock(slug, registryPath, rulesPath, hardRules, softRules) {
|
|
46
|
+
return renderProjectRulesBlock("## Active Project Rules", "These rules are advisory. If a planned action may violate a hard rule, emit a visible warning and proceed unless the user says otherwise.", slug, registryPath, rulesPath, hardRules, softRules);
|
|
47
|
+
}
|
|
48
|
+
export function renderDelegatedProjectRulesPreamble(slug, registryPath, rulesPath, hardRules, softRules) {
|
|
49
|
+
return renderProjectRulesBlock("Project rules for this task:", "These rules are advisory. If this task may violate a hard rule, include a visible warning in your preamble and continue.", slug, registryPath, rulesPath, hardRules, softRules);
|
|
50
|
+
}
|
|
51
|
+
function renderProjectRulesBlock(heading, advisoryLine, slug, registryPath, rulesPath, hardRules, softRules) {
|
|
52
|
+
const lines = [
|
|
53
|
+
heading,
|
|
54
|
+
"",
|
|
55
|
+
`Project: ${slug}`,
|
|
56
|
+
`Registry path: ${registryPath}`,
|
|
57
|
+
`Rules page: ${rulesPath}`,
|
|
58
|
+
"",
|
|
59
|
+
"Hard rules:",
|
|
60
|
+
...PROJECT_RULE_HARD_FIELD_ORDER.map((field) => `- ${field}: ${String(hardRules[field])}`),
|
|
61
|
+
"",
|
|
62
|
+
"Soft rules:",
|
|
63
|
+
];
|
|
64
|
+
const normalizedSoftRules = softRules.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
65
|
+
if (normalizedSoftRules) {
|
|
66
|
+
lines.push(...normalizedSoftRules.split("\n"));
|
|
67
|
+
}
|
|
68
|
+
lines.push("", advisoryLine);
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=project-rules-injection.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
function createLoadedProjectRules() {
|
|
4
|
+
return {
|
|
5
|
+
found: true,
|
|
6
|
+
path: "pages/projects/chapterhouse/rules.md",
|
|
7
|
+
hard: {
|
|
8
|
+
auto_pr: false,
|
|
9
|
+
require_worktree: true,
|
|
10
|
+
pr_draft_default: true,
|
|
11
|
+
default_branch: "main",
|
|
12
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
13
|
+
test_command: "npm test",
|
|
14
|
+
build_command: "npm run build",
|
|
15
|
+
lint_command: "npm run lint:md",
|
|
16
|
+
require_clean_worktree: true,
|
|
17
|
+
},
|
|
18
|
+
soft: "- Keep tests green.\n- Prefer explicit names.\n",
|
|
19
|
+
warnings: [],
|
|
20
|
+
metadata: {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function loadModule() {
|
|
24
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
25
|
+
return import(new URL(`./project-rules-injection.js?case=${nonce}`, import.meta.url).href);
|
|
26
|
+
}
|
|
27
|
+
test("resolveActiveProjectRules prefers an explicit @project mention", async () => {
|
|
28
|
+
const module = await loadModule();
|
|
29
|
+
const activeRules = module.resolveActiveProjectRules("@project:chapterhouse inspect the worker task prompt", "/work/docs-site", {
|
|
30
|
+
loadRegistry: () => ({
|
|
31
|
+
chapterhouse: "/work/chapterhouse",
|
|
32
|
+
"docs-site": "/work/docs-site",
|
|
33
|
+
}),
|
|
34
|
+
loadProjectRules: () => createLoadedProjectRules(),
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(activeRules, {
|
|
37
|
+
project: {
|
|
38
|
+
slug: "chapterhouse",
|
|
39
|
+
path: "/work/chapterhouse",
|
|
40
|
+
source: "mention",
|
|
41
|
+
},
|
|
42
|
+
rules: createLoadedProjectRules(),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
test("resolveActiveProjectRules falls back to the current project path", async () => {
|
|
46
|
+
const module = await loadModule();
|
|
47
|
+
const activeRules = module.resolveActiveProjectRules("inspect the worker task prompt", "/work/chapterhouse/src/copilot", {
|
|
48
|
+
loadRegistry: () => ({
|
|
49
|
+
chapterhouse: "/work/chapterhouse",
|
|
50
|
+
}),
|
|
51
|
+
loadProjectRules: () => createLoadedProjectRules(),
|
|
52
|
+
});
|
|
53
|
+
assert.deepEqual(activeRules, {
|
|
54
|
+
project: {
|
|
55
|
+
slug: "chapterhouse",
|
|
56
|
+
path: "/work/chapterhouse",
|
|
57
|
+
source: "selectedProjectPath",
|
|
58
|
+
},
|
|
59
|
+
rules: createLoadedProjectRules(),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
test("renderDelegatedProjectRulesPreamble matches the PRD wording and field order", async () => {
|
|
63
|
+
const module = await loadModule();
|
|
64
|
+
const rules = createLoadedProjectRules();
|
|
65
|
+
assert.equal(module.renderDelegatedProjectRulesPreamble("chapterhouse", "/home/bjk/projects/chapterhouse", rules.path, rules.hard, rules.soft), "Project rules for this task:\n\n"
|
|
66
|
+
+ "Project: chapterhouse\n"
|
|
67
|
+
+ "Registry path: /home/bjk/projects/chapterhouse\n"
|
|
68
|
+
+ "Rules page: pages/projects/chapterhouse/rules.md\n\n"
|
|
69
|
+
+ "Hard rules:\n"
|
|
70
|
+
+ "- auto_pr: false\n"
|
|
71
|
+
+ "- require_worktree: true\n"
|
|
72
|
+
+ "- pr_draft_default: true\n"
|
|
73
|
+
+ "- default_branch: main\n"
|
|
74
|
+
+ "- commit_co_author: Copilot <223556219+Copilot@users.noreply.github.com>\n"
|
|
75
|
+
+ "- test_command: npm test\n"
|
|
76
|
+
+ "- build_command: npm run build\n"
|
|
77
|
+
+ "- lint_command: npm run lint:md\n"
|
|
78
|
+
+ "- require_clean_worktree: true\n\n"
|
|
79
|
+
+ "Soft rules:\n"
|
|
80
|
+
+ "- Keep tests green.\n"
|
|
81
|
+
+ "- Prefer explicit names.\n\n"
|
|
82
|
+
+ "These rules are advisory. If this task may violate a hard rule, include a visible warning in your preamble and continue.");
|
|
83
|
+
});
|
|
84
|
+
//# sourceMappingURL=project-rules-injection.test.js.map
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
function createActiveProjectRules() {
|
|
7
|
+
return {
|
|
8
|
+
project: {
|
|
9
|
+
slug: "chapterhouse",
|
|
10
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
11
|
+
source: "mention",
|
|
12
|
+
},
|
|
13
|
+
rules: {
|
|
14
|
+
found: true,
|
|
15
|
+
path: "pages/projects/chapterhouse/rules.md",
|
|
16
|
+
hard: {
|
|
17
|
+
auto_pr: false,
|
|
18
|
+
require_worktree: true,
|
|
19
|
+
pr_draft_default: true,
|
|
20
|
+
default_branch: "main",
|
|
21
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
22
|
+
test_command: "npm test",
|
|
23
|
+
build_command: "npm run build",
|
|
24
|
+
lint_command: "npm run lint:md",
|
|
25
|
+
require_clean_worktree: true,
|
|
26
|
+
},
|
|
27
|
+
soft: "- Keep tests green.\n- Prefer explicit names.\n",
|
|
28
|
+
warnings: [],
|
|
29
|
+
metadata: {},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function expectedWarningLines() {
|
|
34
|
+
return [
|
|
35
|
+
"⚠️ Project rule warning: this task may violate `auto_pr: false` — proceeding anyway.",
|
|
36
|
+
"⚠️ Project rule warning: this task may violate `require_worktree: true` — proceeding anyway.",
|
|
37
|
+
"⚠️ Project rule warning: this task may violate `pr_draft_default: true` — proceeding anyway.",
|
|
38
|
+
"⚠️ Project rule warning: this task may violate `default_branch: main` — proceeding anyway.",
|
|
39
|
+
"⚠️ Project rule warning: this task may violate `require_clean_worktree: true` — proceeding anyway.",
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
function expectedDelegatedPrompt(task, warningLines = []) {
|
|
43
|
+
const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
|
|
44
|
+
return warningBlock
|
|
45
|
+
+ "Project rules for this task:\n\n"
|
|
46
|
+
+ "Project: chapterhouse\n"
|
|
47
|
+
+ "Registry path: /home/bjk/projects/chapterhouse\n"
|
|
48
|
+
+ "Rules page: pages/projects/chapterhouse/rules.md\n\n"
|
|
49
|
+
+ "Hard rules:\n"
|
|
50
|
+
+ "- auto_pr: false\n"
|
|
51
|
+
+ "- require_worktree: true\n"
|
|
52
|
+
+ "- pr_draft_default: true\n"
|
|
53
|
+
+ "- default_branch: main\n"
|
|
54
|
+
+ "- commit_co_author: Copilot <223556219+Copilot@users.noreply.github.com>\n"
|
|
55
|
+
+ "- test_command: npm test\n"
|
|
56
|
+
+ "- build_command: npm run build\n"
|
|
57
|
+
+ "- lint_command: npm run lint:md\n"
|
|
58
|
+
+ "- require_clean_worktree: true\n\n"
|
|
59
|
+
+ "Soft rules:\n"
|
|
60
|
+
+ "- Keep tests green.\n"
|
|
61
|
+
+ "- Prefer explicit names.\n\n"
|
|
62
|
+
+ "These rules are advisory. If this task may violate a hard rule, include a visible warning in your preamble and continue.\n\n"
|
|
63
|
+
+ task;
|
|
64
|
+
}
|
|
65
|
+
async function loadToolsModule(t, options) {
|
|
66
|
+
const sentPrompts = [];
|
|
67
|
+
const taskId = options?.taskId ?? `delegated-task-${Date.now()}-${Math.random()}`;
|
|
68
|
+
const fakeSession = {
|
|
69
|
+
on: () => () => { },
|
|
70
|
+
async sendAndWait(request) {
|
|
71
|
+
sentPrompts.push(request.prompt);
|
|
72
|
+
return { data: { content: `handled: ${request.prompt}` } };
|
|
73
|
+
},
|
|
74
|
+
async destroy() { },
|
|
75
|
+
};
|
|
76
|
+
t.mock.module("./orchestrator.js", {
|
|
77
|
+
namedExports: {
|
|
78
|
+
getCurrentSourceChannel: () => options?.sourceChannel ?? "web",
|
|
79
|
+
getCurrentActivityCallback: () => undefined,
|
|
80
|
+
getCurrentAuthenticatedUser: () => undefined,
|
|
81
|
+
getLastAuthenticatedUser: () => undefined,
|
|
82
|
+
getCurrentAuthorizationHeader: () => undefined,
|
|
83
|
+
getCurrentSessionKey: () => "session-test",
|
|
84
|
+
getCurrentActiveProjectRules: () => options?.activeProjectRules ?? null,
|
|
85
|
+
switchSessionModel: async () => { },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
t.mock.module("./agents.js", {
|
|
89
|
+
namedExports: {
|
|
90
|
+
getAgentRegistry: () => [{ slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }],
|
|
91
|
+
getAgent: (name) => name === "coder"
|
|
92
|
+
? { slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }
|
|
93
|
+
: undefined,
|
|
94
|
+
createEphemeralAgentSession: async () => fakeSession,
|
|
95
|
+
getAgentSessionStatus: () => ({ tasks: [] }),
|
|
96
|
+
getActiveTasks: () => [],
|
|
97
|
+
getTask: () => undefined,
|
|
98
|
+
registerTask: () => ({
|
|
99
|
+
taskId,
|
|
100
|
+
agentSlug: "coder",
|
|
101
|
+
description: "Show dispatched prompt",
|
|
102
|
+
status: "running",
|
|
103
|
+
startedAt: Date.now(),
|
|
104
|
+
originChannel: options?.sourceChannel ?? "web",
|
|
105
|
+
}),
|
|
106
|
+
completeTask: () => { },
|
|
107
|
+
failTask: () => { },
|
|
108
|
+
createAgentFile: () => "",
|
|
109
|
+
removeAgentFile: () => { },
|
|
110
|
+
loadAgents: () => [],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const module = await import(new URL(`./tools.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
114
|
+
return { module, sentPrompts, taskId };
|
|
115
|
+
}
|
|
116
|
+
test.beforeEach(() => {
|
|
117
|
+
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-agent-"));
|
|
118
|
+
});
|
|
119
|
+
test.afterEach(async () => {
|
|
120
|
+
const home = process.env.CHAPTERHOUSE_HOME;
|
|
121
|
+
if (home) {
|
|
122
|
+
const dbModule = await import("../store/db.js");
|
|
123
|
+
dbModule.closeDb();
|
|
124
|
+
rmSync(home, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
test("delegate_to_agent persists the full dispatched task prompt", async (t) => {
|
|
128
|
+
const promptText = [
|
|
129
|
+
"Inspect the /workers route detail pane.",
|
|
130
|
+
"Return the exact prompt sent to the worker.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
const { module, taskId } = await loadToolsModule(t, {
|
|
133
|
+
sourceChannel: "chat",
|
|
134
|
+
taskId: "delegated-task-001",
|
|
135
|
+
});
|
|
136
|
+
const tools = module.createTools({
|
|
137
|
+
client: { async listModels() { return []; } },
|
|
138
|
+
onAgentTaskComplete: () => { },
|
|
139
|
+
});
|
|
140
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
141
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
142
|
+
await tool.handler({
|
|
143
|
+
agent_name: "coder",
|
|
144
|
+
summary: "Show dispatched prompt",
|
|
145
|
+
task: promptText,
|
|
146
|
+
}, {});
|
|
147
|
+
const dbModule = await import("../store/db.js");
|
|
148
|
+
const db = dbModule.getDb();
|
|
149
|
+
const row = db.prepare(`SELECT description, prompt, session_key, source FROM agent_tasks WHERE task_id = ?`).get(taskId);
|
|
150
|
+
assert.deepEqual(row, {
|
|
151
|
+
description: "Show dispatched prompt",
|
|
152
|
+
prompt: promptText,
|
|
153
|
+
session_key: "session-test",
|
|
154
|
+
source: "adhoc",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
test("delegate_to_agent prepends active project rules to the dispatched task prompt", async (t) => {
|
|
158
|
+
const { module, sentPrompts } = await loadToolsModule(t, {
|
|
159
|
+
activeProjectRules: createActiveProjectRules(),
|
|
160
|
+
});
|
|
161
|
+
const tools = module.createTools({
|
|
162
|
+
client: { async listModels() { return []; } },
|
|
163
|
+
onAgentTaskComplete: () => { },
|
|
164
|
+
});
|
|
165
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
166
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
167
|
+
const task = "Inspect the worker task prompt.\nDo not re-resolve @project:docs-site from this text.";
|
|
168
|
+
await tool.handler({
|
|
169
|
+
agent_name: "coder",
|
|
170
|
+
summary: "Show dispatched prompt",
|
|
171
|
+
task,
|
|
172
|
+
}, {});
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
174
|
+
assert.deepEqual(sentPrompts, [expectedDelegatedPrompt(task)]);
|
|
175
|
+
});
|
|
176
|
+
test("delegate_to_agent prepends project rule warnings above the dispatched task prompt", async (t) => {
|
|
177
|
+
const { module, sentPrompts } = await loadToolsModule(t, {
|
|
178
|
+
activeProjectRules: createActiveProjectRules(),
|
|
179
|
+
});
|
|
180
|
+
const tools = module.createTools({
|
|
181
|
+
client: { async listModels() { return []; } },
|
|
182
|
+
onAgentTaskComplete: () => { },
|
|
183
|
+
});
|
|
184
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
185
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
186
|
+
const task = "Start the refactor directly in the main checkout, open a non-draft PR against develop, and release from the dirty worktree.";
|
|
187
|
+
await tool.handler({
|
|
188
|
+
agent_name: "coder",
|
|
189
|
+
summary: "Show warning preamble",
|
|
190
|
+
task,
|
|
191
|
+
}, {});
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
193
|
+
assert.deepEqual(sentPrompts, [expectedDelegatedPrompt(task, expectedWarningLines())]);
|
|
194
|
+
});
|
|
195
|
+
test("delegate_to_agent leaves the prompt unchanged when no active project is resolved", async (t) => {
|
|
196
|
+
const { module, sentPrompts } = await loadToolsModule(t, {
|
|
197
|
+
activeProjectRules: null,
|
|
198
|
+
});
|
|
199
|
+
const tools = module.createTools({
|
|
200
|
+
client: { async listModels() { return []; } },
|
|
201
|
+
onAgentTaskComplete: () => { },
|
|
202
|
+
});
|
|
203
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
204
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
205
|
+
const task = "Inspect the worker task prompt without project context.";
|
|
206
|
+
await tool.handler({
|
|
207
|
+
agent_name: "coder",
|
|
208
|
+
summary: "Show dispatched prompt",
|
|
209
|
+
task,
|
|
210
|
+
}, {});
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
212
|
+
assert.deepEqual(sentPrompts, [task]);
|
|
213
|
+
});
|
|
214
|
+
//# sourceMappingURL=tools.agent.test.js.map
|
package/dist/copilot/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "path";
|
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
|
-
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
9
|
+
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
10
10
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
11
11
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
12
12
|
import { searchIndex, addToIndex, removeFromIndex, buildIndexEntryForPage } from "../wiki/index-manager.js";
|
|
@@ -20,6 +20,8 @@ import { getCategoryDir, topicPagePath, slugify, entityCategories, FLAT_CATEGORI
|
|
|
20
20
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
21
21
|
import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
22
22
|
import { getAgentRegistry, getAgent, createEphemeralAgentSession, getAgentSessionStatus, getTask, registerTask, completeTask, failTask, createAgentFile, removeAgentFile, loadAgents, } from "./agents.js";
|
|
23
|
+
import { detectProjectRuleWarnings } from "./project-rule-warnings.js";
|
|
24
|
+
import { renderDelegatedProjectRulesPreamble } from "./project-rules-injection.js";
|
|
23
25
|
import { adoGetOkrs, adoOkrSummary, adoUpdateKr } from "../integrations/ado-skill.js";
|
|
24
26
|
import { TeamsNotifier } from "../integrations/teams-notify.js";
|
|
25
27
|
import { TeamPushClient } from "../integrations/team-push.js";
|
|
@@ -117,9 +119,18 @@ export function createTools(deps) {
|
|
|
117
119
|
return `Failed to create session for @${delegatedSlug}: ${msg}`;
|
|
118
120
|
}
|
|
119
121
|
const task = registerTask(delegatedSlug, args.summary, getCurrentSourceChannel());
|
|
122
|
+
const activeProjectRules = getCurrentActiveProjectRules();
|
|
123
|
+
const warningLines = activeProjectRules
|
|
124
|
+
? detectProjectRuleWarnings(args.task, activeProjectRules.rules.hard)
|
|
125
|
+
: [];
|
|
126
|
+
const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
|
|
127
|
+
const taskPrompt = activeProjectRules
|
|
128
|
+
? `${warningBlock}${renderDelegatedProjectRulesPreamble(activeProjectRules.project.slug, activeProjectRules.project.path, activeProjectRules.rules.path, activeProjectRules.rules.hard, activeProjectRules.rules.soft)}\n\n${args.task}`
|
|
129
|
+
: args.task;
|
|
120
130
|
// Persist task to DB
|
|
121
131
|
const db = getDb();
|
|
122
|
-
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status, origin_channel, session_key, source)
|
|
132
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status, origin_channel, session_key, source)
|
|
133
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?, 'adhoc')`).run(task.taskId, delegatedSlug, args.summary, args.task, task.originChannel || null, getCurrentSessionKey());
|
|
123
134
|
// Capture the parent's activity callback so the child session can stream
|
|
124
135
|
// its events back to the originating SSE connection. This survives past
|
|
125
136
|
// the parent assistant turn — the child runs long after the parent's
|
|
@@ -167,7 +178,7 @@ export function createTools(deps) {
|
|
|
167
178
|
// Non-blocking: dispatch and return immediately. Session is always destroyed after.
|
|
168
179
|
(async () => {
|
|
169
180
|
try {
|
|
170
|
-
const result = await session.sendAndWait({ prompt:
|
|
181
|
+
const result = await session.sendAndWait({ prompt: taskPrompt }, timeoutMs);
|
|
171
182
|
const output = result?.data?.content || "No response";
|
|
172
183
|
completeTask(task.taskId, output);
|
|
173
184
|
db.prepare(`UPDATE agent_tasks SET status = 'completed', result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(output.slice(0, 10000), task.taskId);
|