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/store/db.js CHANGED
@@ -37,6 +37,7 @@ export function getDb() {
37
37
  task_id TEXT PRIMARY KEY,
38
38
  agent_slug TEXT NOT NULL,
39
39
  description TEXT NOT NULL,
40
+ prompt TEXT,
40
41
  status TEXT NOT NULL DEFAULT 'running',
41
42
  result TEXT,
42
43
  origin_channel TEXT,
@@ -114,6 +115,9 @@ export function getDb() {
114
115
  if (!taskCols.some((c) => c.name === 'source')) {
115
116
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
116
117
  }
118
+ if (!taskCols.some((c) => c.name === "prompt")) {
119
+ db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
120
+ }
117
121
  // agent_task_events: append-only per-task tool-call activity log for /workers streaming
118
122
  db.exec(`
119
123
  CREATE TABLE IF NOT EXISTS agent_task_events (
@@ -86,6 +86,36 @@ test("getDb migrates legacy conversation_log tables to allow system messages", a
86
86
  dbModule.closeDb();
87
87
  }
88
88
  });
89
+ test("getDb migrates legacy agent_tasks tables to add a nullable prompt column", async () => {
90
+ const seedDb = new Database(dbPath);
91
+ seedDb.exec(`
92
+ CREATE TABLE agent_tasks (
93
+ task_id TEXT PRIMARY KEY,
94
+ agent_slug TEXT NOT NULL,
95
+ description TEXT NOT NULL,
96
+ status TEXT NOT NULL DEFAULT 'running',
97
+ result TEXT,
98
+ origin_channel TEXT,
99
+ started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
100
+ completed_at DATETIME
101
+ )
102
+ `);
103
+ seedDb.close();
104
+ const dbModule = await loadDbModule();
105
+ try {
106
+ const db = dbModule.getDb();
107
+ const cols = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
108
+ assert.ok(cols.some((col) => col.name === "prompt"), "agent_tasks.prompt column must be added by migration");
109
+ const longPrompt = "Investigate worker prompt capture.\n".repeat(30);
110
+ db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, prompt, status)
111
+ VALUES (?, ?, ?, ?, ?)`).run("task-prompt-001", "coder", "Investigate workers", longPrompt, "running");
112
+ const row = db.prepare(`SELECT prompt FROM agent_tasks WHERE task_id = ?`).get("task-prompt-001");
113
+ assert.equal(row.prompt, longPrompt, "prompt text should round-trip without truncation");
114
+ }
115
+ finally {
116
+ dbModule.closeDb();
117
+ }
118
+ });
89
119
  test("getDb prunes oversized conversation logs on startup and during inserts", async () => {
90
120
  const seedDb = new Database(dbPath);
91
121
  seedDb.exec(`
@@ -23,7 +23,7 @@ const KNOWN_WIKI_FRONTMATTER_FIELDS = new Set([
23
23
  "contradictions",
24
24
  "related",
25
25
  ]);
26
- const PROJECT_RULE_HARD_FIELDS = [
26
+ export const PROJECT_RULE_HARD_FIELDS = [
27
27
  "auto_pr",
28
28
  "require_worktree",
29
29
  "pr_draft_default",
package/dist/wiki/lint.js CHANGED
@@ -4,7 +4,7 @@ import { config } from "../config.js";
4
4
  import { WIKI_DIR } from "../paths.js";
5
5
  import { parseIndex } from "./index-manager.js";
6
6
  import { listPages, listSources, readPage } from "./fs.js";
7
- import { parseWikiFrontmatter, validateWikiFrontmatter } from "./frontmatter.js";
7
+ import { parseWikiFrontmatter, validateProjectRulesFrontmatter, validateWikiFrontmatter, } from "./frontmatter.js";
8
8
  import { ACTION_LOG_PATH } from "./log-manager.js";
9
9
  import { TAXONOMY_PATH, loadTaxonomy, parseTaxonomyTags } from "./taxonomy.js";
10
10
  const DEFAULT_STALE_OVERRIDES = {
@@ -96,15 +96,39 @@ export function lintWiki(options = {}) {
96
96
  });
97
97
  }
98
98
  if (resolved.requireFrontmatter && !specialMetaPage) {
99
- const validation = validateWikiFrontmatter(content, allowedTags.length > 0 ? { allowedTags } : undefined);
100
- if (!validation.valid) {
101
- issues.push({
102
- rule: "frontmatter-shape",
103
- severity: "error",
104
- path,
105
- message: validation.errors.map((error) => error.message).join(" "),
106
- suggestion: "Rewrite the page frontmatter to match the canonical wiki shape before the next update.",
107
- });
99
+ const validationOptions = allowedTags.length > 0 ? { allowedTags } : undefined;
100
+ if (isProjectRulesPage(path)) {
101
+ const validation = validateProjectRulesFrontmatter(content, validationOptions);
102
+ if (!validation.valid) {
103
+ issues.push({
104
+ rule: "frontmatter-shape",
105
+ severity: "error",
106
+ path,
107
+ message: validation.errors.map((error) => error.message).join(" "),
108
+ suggestion: "Rewrite the page frontmatter to match the canonical wiki shape before the next update.",
109
+ });
110
+ }
111
+ for (const warning of validation.warnings) {
112
+ issues.push({
113
+ rule: warning.rule,
114
+ severity: "warning",
115
+ path,
116
+ message: warning.message,
117
+ suggestion: "Remove the unknown key or add it to the project-rules schema before relying on it.",
118
+ });
119
+ }
120
+ }
121
+ else {
122
+ const validation = validateWikiFrontmatter(content, validationOptions);
123
+ if (!validation.valid) {
124
+ issues.push({
125
+ rule: "frontmatter-shape",
126
+ severity: "error",
127
+ path,
128
+ message: validation.errors.map((error) => error.message).join(" "),
129
+ suggestion: "Rewrite the page frontmatter to match the canonical wiki shape before the next update.",
130
+ });
131
+ }
108
132
  }
109
133
  }
110
134
  if (resolved.requireUpdated && !autostub && !specialMetaPage && !parsed.updated && !LEGACY_DATE_STAMP_RE.test(body)) {
@@ -377,6 +401,9 @@ function isDecisionMisfile(path) {
377
401
  return path.startsWith(expectedPrefix) && path.split("/").length === 4;
378
402
  });
379
403
  }
404
+ function isProjectRulesPage(path) {
405
+ return /^pages\/projects\/[^/]+\/rules\.md$/.test(path);
406
+ }
380
407
  function isSpecialMetaPage(path) {
381
408
  return SPECIAL_META_PAGE_RE.test(path);
382
409
  }
@@ -202,6 +202,78 @@ test("lintWiki exempts decision and conversation pages from staleness and exempt
202
202
  assert.equal(signatures.includes("heading-depth:pages/shared/autostub.md"), false);
203
203
  assert.equal(signatures.includes("page-size:pages/shared/autostub.md"), false);
204
204
  });
205
+ test("lintWiki accepts valid project rules frontmatter on rules.md pages", async () => {
206
+ const { lint, wikiFs } = await loadModules();
207
+ wikiFs.ensureWikiStructure();
208
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", wikiPage([
209
+ "title: Chapterhouse",
210
+ "summary: Project overview",
211
+ "updated: 2026-05-12",
212
+ "tags: [engineering]",
213
+ ], "# Chapterhouse\n\n## Overview\n\nProject overview body with enough content to stay out of the premature-page bucket."));
214
+ wikiFs.writePage("pages/projects/chapterhouse/rules.md", wikiPage([
215
+ "title: Project rules for chapterhouse",
216
+ "summary: Project-specific operating rules for Chapterhouse and delegated agents.",
217
+ "updated: 2026-05-12",
218
+ "tags: [engineering]",
219
+ "auto_pr: true",
220
+ "require_worktree: false",
221
+ "pr_draft_default: false",
222
+ "default_branch: main",
223
+ 'commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>"',
224
+ 'test_command: "npm test"',
225
+ 'build_command: "npm run build"',
226
+ 'lint_command: "npm run lint"',
227
+ "require_clean_worktree: true",
228
+ ], "# Project rules\n\n## Soft Rules\n\n- Keep rules concise and operational.\n- Record workflow changes here when they become durable."));
229
+ const report = lint.lintWiki();
230
+ assert.equal(report.issues.some((issue) => issue.path === "pages/projects/chapterhouse/rules.md" && issue.rule === "frontmatter-shape"), false);
231
+ assert.equal(report.issues.some((issue) => issue.path === "pages/projects/chapterhouse/rules.md" && issue.rule === "unknown-project-rule-key"), false);
232
+ });
233
+ test("lintWiki reports invalid known project rule field types on rules.md pages", async () => {
234
+ const { lint, wikiFs } = await loadModules();
235
+ wikiFs.ensureWikiStructure();
236
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", wikiPage([
237
+ "title: Chapterhouse",
238
+ "summary: Project overview",
239
+ "updated: 2026-05-12",
240
+ "tags: [engineering]",
241
+ ], "# Chapterhouse\n\n## Overview\n\nProject overview body with enough content to stay out of the premature-page bucket."));
242
+ wikiFs.writePage("pages/projects/chapterhouse/rules.md", wikiPage([
243
+ "title: Project rules for chapterhouse",
244
+ "summary: Project-specific operating rules for Chapterhouse and delegated agents.",
245
+ "updated: 2026-05-12",
246
+ "tags: [engineering]",
247
+ "auto_pr: [true]",
248
+ ], "# Project rules\n\n## Soft Rules\n\n- Keep rules concise and operational."));
249
+ const report = lint.lintWiki();
250
+ const issue = report.issues.find((candidate) => candidate.path === "pages/projects/chapterhouse/rules.md" && candidate.rule === "frontmatter-shape");
251
+ assert.ok(issue);
252
+ assert.equal(issue.severity, "error");
253
+ assert.match(issue.message, /invalid 'auto_pr' type/);
254
+ });
255
+ test("lintWiki warns on unknown project rule keys on rules.md pages", async () => {
256
+ const { lint, wikiFs } = await loadModules();
257
+ wikiFs.ensureWikiStructure();
258
+ wikiFs.writePage("pages/projects/chapterhouse/index.md", wikiPage([
259
+ "title: Chapterhouse",
260
+ "summary: Project overview",
261
+ "updated: 2026-05-12",
262
+ "tags: [engineering]",
263
+ ], "# Chapterhouse\n\n## Overview\n\nProject overview body with enough content to stay out of the premature-page bucket."));
264
+ wikiFs.writePage("pages/projects/chapterhouse/rules.md", wikiPage([
265
+ "title: Project rules for chapterhouse",
266
+ "summary: Project-specific operating rules for Chapterhouse and delegated agents.",
267
+ "updated: 2026-05-12",
268
+ "tags: [engineering]",
269
+ "mystery_setting: true",
270
+ ], "# Project rules\n\n## Soft Rules\n\n- Keep rules concise and operational."));
271
+ const report = lint.lintWiki();
272
+ assert.ok(report.issues.some((issue) => issue.rule === "unknown-project-rule-key" &&
273
+ issue.severity === "warning" &&
274
+ issue.path === "pages/projects/chapterhouse/rules.md" &&
275
+ issue.message.includes("mystery_setting")));
276
+ });
205
277
  test("renderWikiLintReport surfaces contested and low-confidence pages ahead of the general issue list", async () => {
206
278
  const { lint, wikiFs } = await loadModules();
207
279
  wikiFs.writePage("pages/shared/contested.md", wikiPage([
@@ -0,0 +1,160 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { readPage, writePage } from "./fs.js";
3
+ const PROJECTS_INDEX_PATH = "pages/projects/index.md";
4
+ const REGISTRY_HEADING = "## Project Registry";
5
+ const OPENING_FENCE = "```yaml";
6
+ const CLOSING_FENCE = "```";
7
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
8
+ export function loadRegistry() {
9
+ const content = readPage(PROJECTS_INDEX_PATH);
10
+ if (!content)
11
+ return {};
12
+ const section = parseRegistrySection(content);
13
+ if (!section)
14
+ return {};
15
+ return parseRegistryBlock(section.blockLines);
16
+ }
17
+ export function saveRegistry(registry) {
18
+ validateRegistry(registry);
19
+ const renderedSection = renderRegistrySection(registry);
20
+ const current = readPage(PROJECTS_INDEX_PATH);
21
+ if (!current) {
22
+ writePage(PROJECTS_INDEX_PATH, `${renderedSection}\n`);
23
+ return;
24
+ }
25
+ const section = parseRegistrySection(current);
26
+ if (!section) {
27
+ const trimmed = stripTrailingBlankLines(normalizeLineEndings(current));
28
+ const prefix = trimmed ? `${trimmed}\n\n` : "";
29
+ writePage(PROJECTS_INDEX_PATH, `${prefix}${renderedSection}\n`);
30
+ return;
31
+ }
32
+ const before = stripTrailingBlankLines(section.before.join("\n"));
33
+ const after = stripTrailingBlankLines(stripLeadingBlankLines(section.after).join("\n"));
34
+ const pieces = [];
35
+ if (before) {
36
+ pieces.push(before);
37
+ pieces.push("");
38
+ }
39
+ pieces.push(renderedSection);
40
+ if (after) {
41
+ pieces.push("");
42
+ pieces.push(after);
43
+ }
44
+ writePage(PROJECTS_INDEX_PATH, `${pieces.join("\n")}\n`);
45
+ }
46
+ function parseRegistrySection(content) {
47
+ const normalized = normalizeLineEndings(content);
48
+ const lines = normalized.split("\n");
49
+ const headingIndexes = lines
50
+ .map((line, index) => ({ line, index }))
51
+ .filter(({ line }) => line.trim() === REGISTRY_HEADING)
52
+ .map(({ index }) => index);
53
+ if (headingIndexes.length === 0)
54
+ return undefined;
55
+ if (headingIndexes.length > 1) {
56
+ throw new Error("Project registry is malformed: multiple registry sections found.");
57
+ }
58
+ const headingIndex = headingIndexes[0];
59
+ const nextHeadingIndex = findNextHeading(lines, headingIndex + 1);
60
+ const sectionEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex;
61
+ const sectionLines = lines.slice(headingIndex + 1, sectionEnd);
62
+ let cursor = 0;
63
+ while (cursor < sectionLines.length && sectionLines[cursor].trim() === "") {
64
+ cursor += 1;
65
+ }
66
+ if (cursor >= sectionLines.length || sectionLines[cursor].trim() !== OPENING_FENCE) {
67
+ throw new Error("Project registry is malformed: expected a ```yaml fenced block.");
68
+ }
69
+ cursor += 1;
70
+ const blockLines = [];
71
+ while (cursor < sectionLines.length && sectionLines[cursor].trim() !== CLOSING_FENCE) {
72
+ blockLines.push(sectionLines[cursor]);
73
+ cursor += 1;
74
+ }
75
+ if (cursor >= sectionLines.length) {
76
+ throw new Error("Project registry is malformed: missing closing ``` fence.");
77
+ }
78
+ cursor += 1;
79
+ for (; cursor < sectionLines.length; cursor += 1) {
80
+ if (sectionLines[cursor].trim() !== "") {
81
+ throw new Error("Project registry is malformed: unexpected content after the fenced block.");
82
+ }
83
+ }
84
+ return {
85
+ before: lines.slice(0, headingIndex),
86
+ blockLines,
87
+ after: lines.slice(sectionEnd),
88
+ };
89
+ }
90
+ function parseRegistryBlock(lines) {
91
+ const registry = {};
92
+ for (const rawLine of lines) {
93
+ const line = rawLine.trim();
94
+ if (!line)
95
+ continue;
96
+ const separatorIndex = line.indexOf(":");
97
+ if (separatorIndex <= 0) {
98
+ throw new Error(`Project registry is malformed: malformed registry line '${rawLine}'.`);
99
+ }
100
+ const slug = line.slice(0, separatorIndex).trim();
101
+ const path = line.slice(separatorIndex + 1).trim();
102
+ assertValidProjectSlug(slug);
103
+ validatePath(path);
104
+ if (slug in registry) {
105
+ throw new Error(`Project registry is malformed: duplicate project slug '${slug}'.`);
106
+ }
107
+ registry[slug] = path;
108
+ }
109
+ return registry;
110
+ }
111
+ function validateRegistry(registry) {
112
+ for (const [slug, path] of Object.entries(registry)) {
113
+ assertValidProjectSlug(slug);
114
+ validatePath(path);
115
+ }
116
+ }
117
+ export function assertValidProjectSlug(slug) {
118
+ if (!SLUG_RE.test(slug)) {
119
+ throw new Error(`Project registry has invalid project slug '${slug}'. Expected a lowercase slug.`);
120
+ }
121
+ }
122
+ function validatePath(path) {
123
+ if (!path || !isAbsolute(path)) {
124
+ throw new Error(`Project registry path '${path}' must be an absolute path.`);
125
+ }
126
+ }
127
+ function renderRegistrySection(registry) {
128
+ const lines = [
129
+ REGISTRY_HEADING,
130
+ "",
131
+ OPENING_FENCE,
132
+ ...Object.keys(registry)
133
+ .sort()
134
+ .map((slug) => `${slug}: ${registry[slug]}`),
135
+ CLOSING_FENCE,
136
+ ];
137
+ return lines.join("\n");
138
+ }
139
+ function findNextHeading(lines, startIndex) {
140
+ for (let index = startIndex; index < lines.length; index += 1) {
141
+ if (/^##\s+/.test(lines[index])) {
142
+ return index;
143
+ }
144
+ }
145
+ return -1;
146
+ }
147
+ function normalizeLineEndings(content) {
148
+ return content.replace(/\r\n/g, "\n");
149
+ }
150
+ function stripTrailingBlankLines(content) {
151
+ return content.replace(/\n+$/g, "");
152
+ }
153
+ function stripLeadingBlankLines(lines) {
154
+ let start = 0;
155
+ while (start < lines.length && lines[start].trim() === "") {
156
+ start += 1;
157
+ }
158
+ return lines.slice(start);
159
+ }
160
+ //# sourceMappingURL=project-registry.js.map
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `wiki-project-registry-${process.pid}`);
7
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
8
+ async function loadModules() {
9
+ const nonce = `${Date.now()}-${Math.random()}`;
10
+ const projectRegistry = await import(new URL(`./project-registry.js?case=${nonce}`, import.meta.url).href);
11
+ const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
12
+ return { projectRegistry, wikiFs };
13
+ }
14
+ function resetSandbox() {
15
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
16
+ rmSync(sandboxRoot, { recursive: true, force: true });
17
+ }
18
+ test.beforeEach(() => {
19
+ resetSandbox();
20
+ });
21
+ test.after(() => {
22
+ rmSync(sandboxRoot, { recursive: true, force: true });
23
+ });
24
+ test("loadRegistry returns an empty object when pages/projects/index.md is absent", async () => {
25
+ const { projectRegistry } = await loadModules();
26
+ assert.deepEqual(projectRegistry.loadRegistry(), {});
27
+ });
28
+ test("loadRegistry returns an empty object when the projects page has no registry section", async () => {
29
+ const { projectRegistry, wikiFs } = await loadModules();
30
+ wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Project pages live here.\nupdated: 2026-05-12\n---\n\n# Projects\n\nTracked project pages.\n");
31
+ assert.deepEqual(projectRegistry.loadRegistry(), {});
32
+ });
33
+ test("loadRegistry parses the fenced yaml registry block", async () => {
34
+ const { projectRegistry, wikiFs } = await loadModules();
35
+ wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\n## Project Registry\n\n```yaml\nchapterhouse: /home/bjk/projects/chapterhouse\ndocs-site: /home/bjk/Documents/docs site\n```\n");
36
+ assert.deepEqual(projectRegistry.loadRegistry(), {
37
+ chapterhouse: "/home/bjk/projects/chapterhouse",
38
+ "docs-site": "/home/bjk/Documents/docs site",
39
+ });
40
+ });
41
+ test("loadRegistry rejects malformed registry content", async () => {
42
+ const { projectRegistry, wikiFs } = await loadModules();
43
+ wikiFs.writePage("pages/projects/index.md", "# Projects\n\n## Project Registry\n\n```yaml\nChapterHouse: /home/bjk/projects/chapterhouse\nrelative: ./docs-site\nbroken line\nchapterhouse: /home/bjk/projects/chapterhouse\n```\n");
44
+ assert.throws(() => projectRegistry.loadRegistry(), /invalid project slug|absolute path|malformed registry line|duplicate project slug/);
45
+ });
46
+ test("saveRegistry creates a new registry page with deterministic ordering", async () => {
47
+ const { projectRegistry, wikiFs } = await loadModules();
48
+ projectRegistry.saveRegistry({
49
+ "docs-site": "/home/bjk/projects/docs-site",
50
+ chapterhouse: "/home/bjk/projects/chapterhouse",
51
+ });
52
+ assert.equal(wikiFs.readPage("pages/projects/index.md"), "## Project Registry\n\n```yaml\nchapterhouse: /home/bjk/projects/chapterhouse\ndocs-site: /home/bjk/projects/docs-site\n```\n");
53
+ });
54
+ test("saveRegistry rewrites only the registry section and preserves surrounding content", async () => {
55
+ const { projectRegistry, wikiFs } = await loadModules();
56
+ wikiFs.writePage("pages/projects/index.md", "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\nIntro paragraph.\n\n## Project Registry\n\n```yaml\nzeta: /srv/zeta\nalpha: /srv/alpha\n```\n\n## Notes\n\nKeep this section untouched.\n");
57
+ projectRegistry.saveRegistry({
58
+ beta: "/srv/beta",
59
+ alpha: "/srv/alpha",
60
+ });
61
+ assert.equal(wikiFs.readPage("pages/projects/index.md"), "---\ntitle: Projects\nsummary: Canonical project registry.\nupdated: 2026-05-12\n---\n\n# Projects\n\nIntro paragraph.\n\n## Project Registry\n\n```yaml\nalpha: /srv/alpha\nbeta: /srv/beta\n```\n\n## Notes\n\nKeep this section untouched.\n");
62
+ assert.deepEqual(projectRegistry.loadRegistry(), {
63
+ alpha: "/srv/alpha",
64
+ beta: "/srv/beta",
65
+ });
66
+ });
67
+ test("saveRegistry rejects invalid slugs and non-absolute paths", async () => {
68
+ const { projectRegistry } = await loadModules();
69
+ assert.throws(() => projectRegistry.saveRegistry({ ChapterHouse: "/home/bjk/projects/chapterhouse" }), /invalid project slug/);
70
+ assert.throws(() => projectRegistry.saveRegistry({ chapterhouse: "./relative" }), /absolute path/);
71
+ });
72
+ //# sourceMappingURL=project-registry.test.js.map
@@ -0,0 +1,155 @@
1
+ import { parseProjectRulesFrontmatter, parseWikiFrontmatter, PROJECT_RULE_HARD_FIELDS, validateProjectRulesFrontmatter, } from "./frontmatter.js";
2
+ import { readPage, writePage } from "./fs.js";
3
+ import { assertValidProjectSlug } from "./project-registry.js";
4
+ export function getProjectRulesPath(slug) {
5
+ assertValidProjectSlug(slug);
6
+ return `pages/projects/${slug}/rules.md`;
7
+ }
8
+ export function renderInitialProjectRulesPage(slug, updatedAt = new Date()) {
9
+ assertValidProjectSlug(slug);
10
+ return [
11
+ "---",
12
+ `title: Project rules for ${slug}`,
13
+ "summary: Project-specific operating rules for Chapterhouse and delegated agents.",
14
+ `updated: ${updatedAt.toISOString().slice(0, 10)}`,
15
+ "tags: [engineering, workflow]",
16
+ "related: []",
17
+ "---",
18
+ "",
19
+ "## Soft Rules",
20
+ ].join("\n");
21
+ }
22
+ export function loadProjectRules(slug) {
23
+ const path = getProjectRulesPath(slug);
24
+ const content = readPage(path);
25
+ if (content === undefined) {
26
+ return {
27
+ found: false,
28
+ path,
29
+ };
30
+ }
31
+ const validation = validateProjectRulesFrontmatter(content);
32
+ if (!validation.valid) {
33
+ throw new Error(`Project rules page '${path}' is invalid: ${validation.errors.map((error) => error.message).join(" ")}`);
34
+ }
35
+ const { parsed, body, warnings } = parseProjectRulesFrontmatter(content);
36
+ return {
37
+ found: true,
38
+ path,
39
+ hard: parsed.hardRules,
40
+ soft: body,
41
+ warnings,
42
+ metadata: parsed.metadata,
43
+ };
44
+ }
45
+ export function saveProjectRulesHardFields(slug, hard) {
46
+ const path = getProjectRulesPath(slug);
47
+ const content = readPage(path);
48
+ if (content === undefined) {
49
+ throw new Error(`Project rules page '${path}' was not found.`);
50
+ }
51
+ const validation = validateProjectRulesFrontmatter(content);
52
+ if (!validation.valid) {
53
+ throw new Error(`Project rules page '${path}' is invalid: ${validation.errors.map((error) => error.message).join(" ")}`);
54
+ }
55
+ const { parsed, body } = parseWikiFrontmatter(content);
56
+ writePage(path, renderProjectRulesPage(parsed, hard, body));
57
+ }
58
+ export function saveProjectRulesSoftRules(slug, softRules) {
59
+ const path = getProjectRulesPath(slug);
60
+ const content = readPage(path);
61
+ if (content === undefined) {
62
+ throw new Error(`Project rules page '${path}' was not found.`);
63
+ }
64
+ const validation = validateProjectRulesFrontmatter(content);
65
+ if (!validation.valid) {
66
+ throw new Error(`Project rules page '${path}' is invalid: ${validation.errors.map((error) => error.message).join(" ")}`);
67
+ }
68
+ const { parsed } = parseProjectRulesFrontmatter(content);
69
+ writePage(path, renderProjectRulesPage(parsed, parsed.hardRules, renderSoftRulesBody(softRules)));
70
+ }
71
+ export function loadProjectRuleSummary(slug) {
72
+ const path = getProjectRulesPath(slug);
73
+ const content = readPage(path);
74
+ if (content === undefined) {
75
+ return {
76
+ slug,
77
+ path,
78
+ hardRuleCount: null,
79
+ softRuleCount: null,
80
+ };
81
+ }
82
+ const validation = validateProjectRulesFrontmatter(content);
83
+ if (!validation.valid) {
84
+ throw new Error(`Project rules page '${path}' is invalid: ${validation.errors.map((error) => error.message).join(" ")}`);
85
+ }
86
+ const { parsed, body } = parseWikiFrontmatter(content);
87
+ return {
88
+ slug,
89
+ path,
90
+ hardRuleCount: countExplicitHardRuleFields(parsed),
91
+ softRuleCount: listTopLevelSoftRules(body).length,
92
+ };
93
+ }
94
+ function countExplicitHardRuleFields(parsed) {
95
+ return PROJECT_RULE_HARD_FIELDS.filter((field) => Object.prototype.hasOwnProperty.call(parsed, field)).length;
96
+ }
97
+ export function listTopLevelSoftRules(body) {
98
+ return body
99
+ .split("\n")
100
+ .flatMap((line) => {
101
+ const match = /^[-*+]\s+(.+)$/.exec(line);
102
+ return match ? [match[1].trim()] : [];
103
+ });
104
+ }
105
+ function renderSoftRulesBody(softRules) {
106
+ if (softRules.length === 0) {
107
+ return "## Soft Rules";
108
+ }
109
+ return [
110
+ "## Soft Rules",
111
+ "",
112
+ ...softRules.map((rule) => `- ${rule}`),
113
+ ].join("\n");
114
+ }
115
+ function renderProjectRulesPage(frontmatter, hard, body) {
116
+ const lines = [
117
+ "---",
118
+ ...renderKnownFrontmatter(frontmatter),
119
+ ...PROJECT_RULE_HARD_FIELDS.map((field) => `${field}: ${renderFrontmatterValue(hard[field])}`),
120
+ ...Object.entries(frontmatter.metadata).map(([key, value]) => `${key}: ${renderFrontmatterValue(value)}`),
121
+ "---",
122
+ "",
123
+ body,
124
+ ];
125
+ return `${lines.join("\n").replace(/\n+$/g, "")}\n`;
126
+ }
127
+ function renderKnownFrontmatter(frontmatter) {
128
+ const rendered = [];
129
+ appendFrontmatterLine(rendered, "title", frontmatter.title);
130
+ appendFrontmatterLine(rendered, "summary", frontmatter.summary);
131
+ appendFrontmatterLine(rendered, "updated", frontmatter.updated);
132
+ appendFrontmatterLine(rendered, "tags", frontmatter.tags);
133
+ appendFrontmatterLine(rendered, "autostub", frontmatter.autostub);
134
+ appendFrontmatterLine(rendered, "confidence", frontmatter.confidence);
135
+ appendFrontmatterLine(rendered, "contested", frontmatter.contested);
136
+ appendFrontmatterLine(rendered, "contradictions", frontmatter.contradictions);
137
+ appendFrontmatterLine(rendered, "related", frontmatter.related);
138
+ return rendered;
139
+ }
140
+ function appendFrontmatterLine(lines, key, value) {
141
+ if (value === undefined) {
142
+ return;
143
+ }
144
+ lines.push(`${key}: ${renderFrontmatterValue(value)}`);
145
+ }
146
+ function renderFrontmatterValue(value) {
147
+ if (Array.isArray(value)) {
148
+ return `[${value.join(", ")}]`;
149
+ }
150
+ if (typeof value === "boolean") {
151
+ return value ? "true" : "false";
152
+ }
153
+ return value;
154
+ }
155
+ //# sourceMappingURL=project-rules.js.map