chapterhouse 0.3.15 → 0.3.16
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/config.js +1 -1
- package/dist/copilot/tools.js +18 -29
- package/dist/copilot/tools.wiki.test.js +143 -0
- package/dist/wiki/frontmatter.js +148 -0
- package/dist/wiki/frontmatter.test.js +109 -0
- package/dist/wiki/fs.js +3 -3
- package/dist/wiki/fs.test.js +3 -2
- package/dist/wiki/index-manager.js +14 -32
- package/dist/wiki/index-manager.test.js +3 -3
- package/dist/wiki/lint.js +424 -0
- package/dist/wiki/lint.test.js +260 -0
- package/dist/wiki/log-manager.js +52 -9
- package/dist/wiki/log-manager.test.js +47 -0
- package/dist/wiki/taxonomy.js +73 -0
- package/dist/wiki/taxonomy.test.js +70 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BlIWCM11.js → index-BYuMgJ36.js} +61 -61
- package/web/dist/assets/index-BYuMgJ36.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BlIWCM11.js.map +0 -1
package/dist/wiki/log-manager.js
CHANGED
|
@@ -1,20 +1,63 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Wiki log
|
|
2
|
+
// Wiki action-log manager — append-only chronological operation log
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
import { appendFileSync } from "fs";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, renameSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import {
|
|
7
|
-
import { ensureWikiStructure } from "./fs.js";
|
|
8
|
-
const
|
|
6
|
+
import { WIKI_PAGES_DIR } from "../paths.js";
|
|
7
|
+
import { ensureWikiStructure, writeFileAtomic } from "./fs.js";
|
|
8
|
+
export const ACTION_LOG_PATH = "pages/_meta/log.md";
|
|
9
|
+
const LOG_PATH = join(WIKI_PAGES_DIR, "_meta", "log.md");
|
|
10
|
+
const MAX_LOG_ENTRIES = 500;
|
|
11
|
+
const LOG_ENTRY_RE = /^## \[/gm;
|
|
12
|
+
const INITIAL_LOG = `# Wiki Action Log
|
|
13
|
+
|
|
14
|
+
_Append-only record of wiki operations._
|
|
15
|
+
|
|
16
|
+
`;
|
|
9
17
|
/**
|
|
10
|
-
* Append a timestamped entry to log.md.
|
|
11
|
-
* Format: `## [YYYY-MM-DD HH:MM]
|
|
18
|
+
* Append a timestamped entry to pages/_meta/log.md.
|
|
19
|
+
* Format: `## [YYYY-MM-DD HH:MM] action | subject | agent`
|
|
12
20
|
*/
|
|
13
|
-
export function appendLog(type,
|
|
21
|
+
export function appendLog(type, subject, agent = resolveAgentName()) {
|
|
14
22
|
ensureWikiStructure();
|
|
23
|
+
rotateLogIfNeeded();
|
|
15
24
|
const now = new Date();
|
|
16
25
|
const ts = now.toISOString().slice(0, 16).replace("T", " ");
|
|
17
|
-
const entry = `## [${ts}] ${type} | ${
|
|
26
|
+
const entry = `## [${ts}] ${type} | ${subject} | ${agent}\n\n`;
|
|
18
27
|
appendFileSync(LOG_PATH, entry, "utf-8");
|
|
19
28
|
}
|
|
29
|
+
function rotateLogIfNeeded() {
|
|
30
|
+
const currentLog = readFileSync(LOG_PATH, "utf-8");
|
|
31
|
+
const entryCount = (currentLog.match(LOG_ENTRY_RE) ?? []).length;
|
|
32
|
+
if (entryCount < MAX_LOG_ENTRIES) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const archivePath = join(WIKI_PAGES_DIR, "_meta", `log-${new Date().getFullYear()}.md`);
|
|
36
|
+
if (!existsSync(archivePath)) {
|
|
37
|
+
renameSync(LOG_PATH, archivePath);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const archivedEntries = extractEntries(currentLog);
|
|
41
|
+
if (archivedEntries.length > 0) {
|
|
42
|
+
const separator = readFileSync(archivePath, "utf-8").endsWith("\n\n") ? "" : "\n\n";
|
|
43
|
+
appendFileSync(archivePath, `${separator}${archivedEntries.join("\n\n")}\n\n`, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
writeFileAtomic(LOG_PATH, INITIAL_LOG);
|
|
47
|
+
}
|
|
48
|
+
function extractEntries(content) {
|
|
49
|
+
return content
|
|
50
|
+
.split(/\n{2,}/)
|
|
51
|
+
.map((chunk) => chunk.trim())
|
|
52
|
+
.filter((chunk) => chunk.startsWith("## ["));
|
|
53
|
+
}
|
|
54
|
+
function resolveAgentName() {
|
|
55
|
+
const candidates = [
|
|
56
|
+
process.env.CHAPTERHOUSE_SESSION_AGENT_NAME,
|
|
57
|
+
process.env.CHAPTERHOUSE_AGENT_NAME,
|
|
58
|
+
process.env.COPILOT_AGENT_NAME,
|
|
59
|
+
process.env.AGENT_NAME,
|
|
60
|
+
];
|
|
61
|
+
return candidates.find((candidate) => candidate && candidate.trim().length > 0)?.trim() ?? "unknown";
|
|
62
|
+
}
|
|
20
63
|
//# sourceMappingURL=log-manager.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, 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-log-${process.pid}`);
|
|
7
|
+
const wikiDir = join(sandboxRoot, ".chapterhouse", "wiki");
|
|
8
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
9
|
+
process.env.CHAPTERHOUSE_AGENT_NAME = "wiki-test-agent";
|
|
10
|
+
async function loadModules() {
|
|
11
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
12
|
+
const logManager = await import(new URL(`./log-manager.js?case=${nonce}`, import.meta.url).href);
|
|
13
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
14
|
+
return { logManager, wikiFs };
|
|
15
|
+
}
|
|
16
|
+
function resetSandbox() {
|
|
17
|
+
mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
|
|
18
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
test.beforeEach(() => {
|
|
21
|
+
resetSandbox();
|
|
22
|
+
});
|
|
23
|
+
test.after(() => {
|
|
24
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
test("appendLog writes action entries to pages/_meta/log.md with the resolved agent name", async () => {
|
|
27
|
+
const { logManager, wikiFs } = await loadModules();
|
|
28
|
+
wikiFs.ensureWikiStructure();
|
|
29
|
+
logManager.appendLog("update", "wiki_update: Chapterhouse (pages/shared/chapterhouse.md)");
|
|
30
|
+
assert.equal(existsSync(join(wikiDir, "pages", "_meta", "log.md")), true);
|
|
31
|
+
assert.match(wikiFs.readLogFile(), /^## \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\] update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| wiki-test-agent/m);
|
|
32
|
+
});
|
|
33
|
+
test("appendLog rotates the active log after 500 entries and keeps appending to a fresh log", async () => {
|
|
34
|
+
const { logManager, wikiFs } = await loadModules();
|
|
35
|
+
wikiFs.ensureWikiStructure();
|
|
36
|
+
wikiFs.writeLogFile("# Wiki Action Log\n\n" +
|
|
37
|
+
Array.from({ length: 500 }, (_, index) => `## [2026-01-01 00:00] update | historical entry ${index + 1} | wiki-test-agent\n\n`).join(""));
|
|
38
|
+
logManager.appendLog("update", "fresh entry after rotation");
|
|
39
|
+
const archiveYear = String(new Date().getFullYear());
|
|
40
|
+
const activeLog = wikiFs.readLogFile();
|
|
41
|
+
const archivedLog = wikiFs.readPage(`pages/_meta/log-${archiveYear}.md`);
|
|
42
|
+
assert.ok(archivedLog);
|
|
43
|
+
assert.match(archivedLog, /historical entry 1/);
|
|
44
|
+
assert.doesNotMatch(activeLog, /historical entry 1/);
|
|
45
|
+
assert.match(activeLog, /fresh entry after rotation/);
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=log-manager.test.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readPage } from "./fs.js";
|
|
2
|
+
const ENGINEERING_TAGS = [
|
|
3
|
+
"engineering",
|
|
4
|
+
"architecture",
|
|
5
|
+
"release",
|
|
6
|
+
"runbook",
|
|
7
|
+
"incident",
|
|
8
|
+
"orchestration",
|
|
9
|
+
];
|
|
10
|
+
const PEOPLE_TAGS = [
|
|
11
|
+
"people",
|
|
12
|
+
"person",
|
|
13
|
+
"org",
|
|
14
|
+
"team",
|
|
15
|
+
"tool",
|
|
16
|
+
];
|
|
17
|
+
const PROCESS_TAGS = [
|
|
18
|
+
"process",
|
|
19
|
+
"project",
|
|
20
|
+
"topic",
|
|
21
|
+
"area",
|
|
22
|
+
"decision",
|
|
23
|
+
"preference",
|
|
24
|
+
"fact",
|
|
25
|
+
"routine",
|
|
26
|
+
"research",
|
|
27
|
+
];
|
|
28
|
+
const META_TAGS = [
|
|
29
|
+
"meta",
|
|
30
|
+
"index",
|
|
31
|
+
"source",
|
|
32
|
+
"taxonomy",
|
|
33
|
+
"autostub",
|
|
34
|
+
];
|
|
35
|
+
export const DEFAULT_TAGS = [
|
|
36
|
+
...ENGINEERING_TAGS,
|
|
37
|
+
...PEOPLE_TAGS,
|
|
38
|
+
...PROCESS_TAGS,
|
|
39
|
+
...META_TAGS,
|
|
40
|
+
];
|
|
41
|
+
export const TAXONOMY_PATH = "pages/_meta/taxonomy.md";
|
|
42
|
+
export function parseTaxonomyTags(content) {
|
|
43
|
+
const tags = new Set();
|
|
44
|
+
let seenHeading = false;
|
|
45
|
+
for (const rawLine of content.split("\n")) {
|
|
46
|
+
const line = rawLine.trim();
|
|
47
|
+
if (!line)
|
|
48
|
+
continue;
|
|
49
|
+
if (/^##\s+.+/.test(line)) {
|
|
50
|
+
seenHeading = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const bulletMatch = line.match(/^-\s+([a-z0-9][a-z0-9-]*)$/i);
|
|
54
|
+
if (bulletMatch && seenHeading) {
|
|
55
|
+
tags.add(bulletMatch[1].toLowerCase());
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Invalid wiki taxonomy at ${TAXONOMY_PATH}: expected '## Group' headings with '- tag' bullets.`);
|
|
59
|
+
}
|
|
60
|
+
return [...tags];
|
|
61
|
+
}
|
|
62
|
+
export function loadTaxonomy() {
|
|
63
|
+
const tags = new Set(DEFAULT_TAGS);
|
|
64
|
+
const override = readPage(TAXONOMY_PATH);
|
|
65
|
+
if (!override) {
|
|
66
|
+
return [...tags].sort();
|
|
67
|
+
}
|
|
68
|
+
for (const tag of parseTaxonomyTags(override)) {
|
|
69
|
+
tags.add(tag);
|
|
70
|
+
}
|
|
71
|
+
return [...tags].sort();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=taxonomy.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
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-taxonomy-${process.pid}`);
|
|
7
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
8
|
+
async function loadModules() {
|
|
9
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
10
|
+
const taxonomy = await import(new URL(`./taxonomy.js?case=${nonce}`, import.meta.url).href);
|
|
11
|
+
const wikiFs = await import(new URL(`./fs.js?case=${nonce}`, import.meta.url).href);
|
|
12
|
+
return { taxonomy, 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("loadTaxonomy returns the default tags when no wiki taxonomy file exists", async () => {
|
|
25
|
+
const { taxonomy } = await loadModules();
|
|
26
|
+
const merged = taxonomy.loadTaxonomy();
|
|
27
|
+
assert.deepEqual(merged, [...taxonomy.DEFAULT_TAGS].sort());
|
|
28
|
+
});
|
|
29
|
+
test("loadTaxonomy merges pages/_meta/taxonomy.md entries with the defaults", async () => {
|
|
30
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
31
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
32
|
+
- orchestration
|
|
33
|
+
- release
|
|
34
|
+
|
|
35
|
+
## Custom Group
|
|
36
|
+
- teammate
|
|
37
|
+
`);
|
|
38
|
+
const merged = taxonomy.loadTaxonomy();
|
|
39
|
+
assert.equal(merged.includes("orchestration"), true);
|
|
40
|
+
assert.equal(merged.includes("release"), true);
|
|
41
|
+
assert.equal(merged.includes("teammate"), true);
|
|
42
|
+
assert.equal(merged.includes("engineering"), true);
|
|
43
|
+
});
|
|
44
|
+
test("loadTaxonomy deduplicates repeated tags across defaults and overrides", async () => {
|
|
45
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
46
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
47
|
+
- engineering
|
|
48
|
+
- release
|
|
49
|
+
- release
|
|
50
|
+
`);
|
|
51
|
+
const merged = taxonomy.loadTaxonomy();
|
|
52
|
+
assert.equal(merged.filter((tag) => tag === "engineering").length, 1);
|
|
53
|
+
assert.equal(merged.filter((tag) => tag === "release").length, 1);
|
|
54
|
+
});
|
|
55
|
+
test("loadTaxonomy accepts arbitrary heading labels as grouping metadata", async () => {
|
|
56
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
57
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Totally New Bucket
|
|
58
|
+
- field-note
|
|
59
|
+
`);
|
|
60
|
+
const merged = taxonomy.loadTaxonomy();
|
|
61
|
+
assert.equal(merged.includes("field-note"), true);
|
|
62
|
+
});
|
|
63
|
+
test("loadTaxonomy rejects malformed taxonomy markdown clearly", async () => {
|
|
64
|
+
const { taxonomy, wikiFs } = await loadModules();
|
|
65
|
+
wikiFs.writePage("pages/_meta/taxonomy.md", `## Engineering
|
|
66
|
+
not-a-bullet
|
|
67
|
+
`);
|
|
68
|
+
assert.throws(() => taxonomy.loadTaxonomy(), /Invalid wiki taxonomy/i);
|
|
69
|
+
});
|
|
70
|
+
//# sourceMappingURL=taxonomy.test.js.map
|
package/package.json
CHANGED