claude-code-session-manager 0.55.1 → 0.57.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/dist/assets/{TiptapBody-g5zDcgLc.js → TiptapBody-B1llI4h-.js} +1 -1
- package/dist/assets/index-DdVGnaCd.js +3227 -0
- package/dist/assets/{index-CMQ3X1Y6.css → index-WUc_GHMf.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +136 -0
- package/src/main/__tests__/bilkoHost-deriveSlug.test.cjs +26 -0
- package/src/main/__tests__/bilkoHost-integration.test.cjs +105 -0
- package/src/main/__tests__/bilkoHostCore.test.cjs +72 -0
- package/src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs +105 -0
- package/src/main/agentLibrary.cjs +5 -2
- package/src/main/bilkoHost.cjs +305 -0
- package/src/main/bilkoHostCore.cjs +89 -0
- package/src/main/config.cjs +8 -8
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +29 -0
- package/src/main/lib/epicMint.cjs +6 -0
- package/src/main/lib/opsOwnership.cjs +5 -2
- package/src/main/projectPages.cjs +3 -2
- package/src/main/scheduler.cjs +33 -2
- package/src/preload/api.d.ts +87 -2
- package/src/preload/index.cjs +7 -0
- package/dist/assets/index-tW8C4h-7.js +0 -3221
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// bilkoHostCore.cjs — pure multi-document bundle logic for the "Host on
|
|
2
|
+
// Bilko.run" tab (subpath validation, document-set invariants, path/URL math).
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const {
|
|
5
|
+
validateSubpath,
|
|
6
|
+
validateDocumentSet,
|
|
7
|
+
documentDistRelPath,
|
|
8
|
+
documentUrl,
|
|
9
|
+
} = require('../bilkoHostCore.cjs');
|
|
10
|
+
|
|
11
|
+
test('validateSubpath: "" (root) is always valid', () => {
|
|
12
|
+
assert.equal(validateSubpath('').ok, true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('validateSubpath: accepts lowercase kebab-case, single or multi-segment', () => {
|
|
16
|
+
assert.equal(validateSubpath('special-doc').ok, true);
|
|
17
|
+
assert.equal(validateSubpath('special-doc/01').ok, true);
|
|
18
|
+
assert.equal(validateSubpath('a/b/c').ok, true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('validateSubpath: rejects leading/trailing slash', () => {
|
|
22
|
+
assert.equal(validateSubpath('/foo').ok, false);
|
|
23
|
+
assert.equal(validateSubpath('foo/').ok, false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('validateSubpath: rejects uppercase, spaces, dot-segments', () => {
|
|
27
|
+
assert.equal(validateSubpath('Foo').ok, false);
|
|
28
|
+
assert.equal(validateSubpath('foo bar').ok, false);
|
|
29
|
+
assert.equal(validateSubpath('..').ok, false);
|
|
30
|
+
assert.equal(validateSubpath('foo/../bar').ok, false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('validateSubpath: rejects excessive depth or length', () => {
|
|
34
|
+
assert.equal(validateSubpath(Array(10).fill('a').join('/')).ok, false);
|
|
35
|
+
assert.equal(validateSubpath('a'.repeat(300)).ok, false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('validateDocumentSet: requires exactly one root document', () => {
|
|
39
|
+
assert.equal(validateDocumentSet([]).ok, false);
|
|
40
|
+
assert.equal(validateDocumentSet([{ id: '1', subpath: 'x' }]).ok, false);
|
|
41
|
+
assert.equal(validateDocumentSet([{ id: '1', subpath: '' }]).ok, true);
|
|
42
|
+
assert.equal(
|
|
43
|
+
validateDocumentSet([{ id: '1', subpath: '' }, { id: '2', subpath: '' }]).ok,
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('validateDocumentSet: rejects duplicate subpaths', () => {
|
|
49
|
+
const res = validateDocumentSet([
|
|
50
|
+
{ id: '1', subpath: '' },
|
|
51
|
+
{ id: '2', subpath: 'doc' },
|
|
52
|
+
{ id: '3', subpath: 'doc' },
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(res.ok, false);
|
|
55
|
+
assert.match(res.error, /duplicate subpath/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('validateDocumentSet: propagates a bad segment error with the document id', () => {
|
|
59
|
+
const res = validateDocumentSet([{ id: '1', subpath: '' }, { id: 'bad-doc', subpath: 'Not Ok' }]);
|
|
60
|
+
assert.equal(res.ok, false);
|
|
61
|
+
assert.match(res.error, /bad-doc/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('documentDistRelPath: root -> index.html, sub-path -> <subpath>/index.html', () => {
|
|
65
|
+
assert.equal(documentDistRelPath(''), 'index.html');
|
|
66
|
+
assert.equal(documentDistRelPath('special-doc/01'), 'special-doc/01/index.html');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('documentUrl: root has no sub-path segment, sub-path is nested under the slug', () => {
|
|
70
|
+
assert.equal(documentUrl('my-app', ''), 'https://bilko.run/projects/my-app/');
|
|
71
|
+
assert.equal(documentUrl('my-app', 'special-doc/01'), 'https://bilko.run/projects/my-app/special-doc/01/');
|
|
72
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-history-backfill.test.cjs — regression test for the
|
|
3
|
+
* bug found 2026-08-02 while investigating why session-manager's own
|
|
4
|
+
* Scheduler tab showed an empty Queue AND an empty History: a completed
|
|
5
|
+
* job's PRD `.md` is archived immediately (archiveCompletedPrd, called right
|
|
6
|
+
* when the job finishes) — long before HISTORY_RETENTION_MS (7 days) would
|
|
7
|
+
* ever cause queueHistory.partitionJobs to append it to history.jsonl. The
|
|
8
|
+
* very next reconcile() pass then found the .md gone and silently dropped
|
|
9
|
+
* the job's row from jobs[] via the "terminal job whose .md is gone was
|
|
10
|
+
* archived on purpose" branch, without ever writing it to history.jsonl —
|
|
11
|
+
* total loss of the completed job's record.
|
|
12
|
+
*
|
|
13
|
+
* reconcile() must now backfill history.jsonl for exactly this case before
|
|
14
|
+
* dropping the row.
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import { test, expect, afterEach, vi } from 'vitest';
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const tmpDirs = [];
|
|
27
|
+
|
|
28
|
+
function makeFixtureCwd() {
|
|
29
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-history-'));
|
|
30
|
+
tmpDirs.push(cwd);
|
|
31
|
+
return cwd;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.resetModules();
|
|
36
|
+
while (tmpDirs.length) {
|
|
37
|
+
const dir = tmpDirs.pop();
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('reconcile() backfills history.jsonl for a completed job whose PRD file is already gone', async () => {
|
|
43
|
+
const cwd = makeFixtureCwd();
|
|
44
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
45
|
+
fs.mkdirSync(path.join(opsRoot, 'scheduler', 'prds'), { recursive: true });
|
|
46
|
+
fs.mkdirSync(path.join(opsRoot, 'scheduler', 'state'), { recursive: true });
|
|
47
|
+
|
|
48
|
+
const { reconcile } = require('../scheduler.cjs');
|
|
49
|
+
|
|
50
|
+
const job = {
|
|
51
|
+
slug: '999-vanished-prd',
|
|
52
|
+
title: 'Vanished PRD',
|
|
53
|
+
cwd,
|
|
54
|
+
status: 'completed',
|
|
55
|
+
finishedAt: new Date().toISOString(),
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
error: null,
|
|
58
|
+
runId: 'run-1',
|
|
59
|
+
};
|
|
60
|
+
// No matching .md anywhere under prds/ or prds-archived/ for this slug —
|
|
61
|
+
// simulates the file having already been archived by archiveCompletedPrd
|
|
62
|
+
// in the same tick the job finished, well before this reconcile() call.
|
|
63
|
+
const state = { jobs: [job] };
|
|
64
|
+
|
|
65
|
+
await reconcile(state);
|
|
66
|
+
|
|
67
|
+
expect(state.jobs.find((j) => j.slug === job.slug)).toBeUndefined();
|
|
68
|
+
|
|
69
|
+
const historyPath = path.join(opsRoot, 'scheduler', 'state', 'history.jsonl');
|
|
70
|
+
expect(fs.existsSync(historyPath)).toBe(true);
|
|
71
|
+
const lines = fs.readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
72
|
+
const recorded = lines.map((l) => JSON.parse(l)).find((e) => e.slug === job.slug);
|
|
73
|
+
expect(recorded).toBeDefined();
|
|
74
|
+
expect(recorded.status).toBe('completed');
|
|
75
|
+
expect(recorded.runId).toBe('run-1');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reconcile() does not double-write history for a job already recorded there', async () => {
|
|
79
|
+
const cwd = makeFixtureCwd();
|
|
80
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
81
|
+
fs.mkdirSync(path.join(opsRoot, 'scheduler', 'prds'), { recursive: true });
|
|
82
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
83
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
const job = {
|
|
86
|
+
slug: '998-already-recorded',
|
|
87
|
+
title: 'Already recorded',
|
|
88
|
+
cwd,
|
|
89
|
+
status: 'completed',
|
|
90
|
+
finishedAt: new Date().toISOString(),
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
error: null,
|
|
93
|
+
runId: 'run-2',
|
|
94
|
+
};
|
|
95
|
+
const historyPath = path.join(stateDir, 'history.jsonl');
|
|
96
|
+
fs.writeFileSync(historyPath, JSON.stringify(job) + '\n', 'utf8');
|
|
97
|
+
|
|
98
|
+
const { reconcile } = require('../scheduler.cjs');
|
|
99
|
+
const state = { jobs: [job] };
|
|
100
|
+
await reconcile(state);
|
|
101
|
+
|
|
102
|
+
const lines = fs.readFileSync(historyPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
103
|
+
const matches = lines.map((l) => JSON.parse(l)).filter((e) => e.slug === job.slug);
|
|
104
|
+
expect(matches.length).toBe(1);
|
|
105
|
+
});
|
|
@@ -42,13 +42,14 @@ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
42
42
|
* assign/remove the relationship from either side. Stored the same
|
|
43
43
|
* comma-list way as `tools` for one parser to cover both.
|
|
44
44
|
*/
|
|
45
|
-
function serializePersona({ name, description, tools, model, color, tags, body }) {
|
|
45
|
+
function serializePersona({ name, description, tools, model, color, tags, title, body }) {
|
|
46
46
|
const lines = ['---', `name: ${name}`];
|
|
47
47
|
if (description) lines.push(`description: ${description}`);
|
|
48
48
|
if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
|
|
49
49
|
if (model && model !== 'inherit') lines.push(`model: ${model}`);
|
|
50
50
|
if (color) lines.push(`color: ${color}`);
|
|
51
51
|
if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
|
|
52
|
+
if (title) lines.push(`title: ${title}`);
|
|
52
53
|
lines.push('---', '');
|
|
53
54
|
return lines.join('\n') + (body || '').trim() + '\n';
|
|
54
55
|
}
|
|
@@ -66,6 +67,7 @@ async function savePersona({
|
|
|
66
67
|
model,
|
|
67
68
|
color,
|
|
68
69
|
tags,
|
|
70
|
+
title,
|
|
69
71
|
body,
|
|
70
72
|
globalDir = path.join(os.homedir(), '.claude', 'agents'),
|
|
71
73
|
validatePath = configMgr.validatePath,
|
|
@@ -75,7 +77,7 @@ async function savePersona({
|
|
|
75
77
|
throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
|
|
76
78
|
}
|
|
77
79
|
const target = validatePath(path.join(globalDir, `${name}.md`));
|
|
78
|
-
const text = serializePersona({ name, description, tools, model, color, tags, body });
|
|
80
|
+
const text = serializePersona({ name, description, tools, model, color, tags, title, body });
|
|
79
81
|
await writeTextAtomic(target, text);
|
|
80
82
|
if (originalName && originalName !== name) {
|
|
81
83
|
const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
|
|
@@ -201,6 +203,7 @@ async function listPersonas({
|
|
|
201
203
|
model: fm.model || null,
|
|
202
204
|
color: fm.color || null,
|
|
203
205
|
tags: parseTools(fm.tags),
|
|
206
|
+
title: fm.title || null,
|
|
204
207
|
path: real,
|
|
205
208
|
body: body.trim(),
|
|
206
209
|
overridingProjects,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bilkoHost.cjs — Stage A (deterministic bundle prep) for the "Host on
|
|
5
|
+
* Bilko.run" tab. See session-manager-operations/architecture/
|
|
6
|
+
* bilko-host-integration.md for the full design.
|
|
7
|
+
*
|
|
8
|
+
* Model: ONE Bilko project (slug) hosts a LIST of documents — a root
|
|
9
|
+
* document (subpath '', dist/index.html — "the project itself") plus any
|
|
10
|
+
* number of sub-path documents (subpath 'special-doc/01',
|
|
11
|
+
* dist/special-doc/01/index.html → bilko.run/projects/<slug>/special-doc/01/).
|
|
12
|
+
* `documents.json` is the list; `dist/` is always rebuilt wholesale from it
|
|
13
|
+
* (rm + rewrite) so it can never drift from the list — the same "full
|
|
14
|
+
* replace" semantics Bilko's own publish_static_project already uses, which
|
|
15
|
+
* is exactly what makes local deletion reliably propagate live (see
|
|
16
|
+
* bilkoHostCore.cjs's header comment).
|
|
17
|
+
*
|
|
18
|
+
* This module owns `session-manager-operations/bilko-host/documents.json` +
|
|
19
|
+
* `dist/{**,manifest.json}` (writer id `bilko-host`, OWNERS-enforced via
|
|
20
|
+
* config.cjs's write helpers). It never talks to the bilko-host MCP itself
|
|
21
|
+
* and never writes publish-state.json — that's Stage B, run by the
|
|
22
|
+
* bilko-host-publisher Epic's own session with its own Write tool
|
|
23
|
+
* (agent-authored artifact, same as project-pages/output/*.html; not
|
|
24
|
+
* enforceable by OWNERS). Directory removal (rm -rf of dist/ before a
|
|
25
|
+
* rebuild) uses raw fs directly, same precedent as epicMint.cjs/
|
|
26
|
+
* queueStore.cjs using raw fs for their own owned namespace — this module
|
|
27
|
+
* IS the declared owner of `bilko-host`, so there is no foreign-writer risk
|
|
28
|
+
* to guard against here.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { ipcMain } = require('electron');
|
|
32
|
+
const fsp = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const crypto = require('node:crypto');
|
|
35
|
+
const { execFileSync } = require('node:child_process');
|
|
36
|
+
const { gzipSync } = require('node:zlib');
|
|
37
|
+
const config = require('./config.cjs');
|
|
38
|
+
const {
|
|
39
|
+
validateSubpath,
|
|
40
|
+
validateDocumentSet,
|
|
41
|
+
documentDistRelPath,
|
|
42
|
+
documentUrl,
|
|
43
|
+
} = require('./bilkoHostCore.cjs');
|
|
44
|
+
|
|
45
|
+
const WRITER = 'bilko-host';
|
|
46
|
+
const PROJECT_PAGE_LENSES = new Set(['home', 'marketing', 'feature', 'architecture']);
|
|
47
|
+
|
|
48
|
+
function bilkoHostDir(cwd) {
|
|
49
|
+
return path.join(cwd, 'session-manager-operations', 'bilko-host');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function distDir(cwd) {
|
|
53
|
+
return path.join(bilkoHostDir(cwd), 'dist');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function documentsPath(cwd) {
|
|
57
|
+
return path.join(bilkoHostDir(cwd), 'documents.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Kebab-case a project name into a slug candidate. Pure. */
|
|
61
|
+
function deriveSlug(projectDirName) {
|
|
62
|
+
return String(projectDirName)
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.replace(/^-+|-+$/g, '')
|
|
67
|
+
.slice(0, 64) || 'project';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function gitInfo(cwd) {
|
|
71
|
+
const run = (args, fallback) => {
|
|
72
|
+
try {
|
|
73
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
gitSha: run(['rev-parse', '--short', 'HEAD'], '0000000'),
|
|
80
|
+
gitBranch: run(['rev-parse', '--abbrev-ref', 'HEAD'], 'main'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readDocuments(cwd) {
|
|
85
|
+
const result = await config.readJson(documentsPath(cwd));
|
|
86
|
+
if (result.exists && !result.parseError && Array.isArray(result.data?.documents)) {
|
|
87
|
+
return result.data.documents;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeDocuments(cwd, documents) {
|
|
93
|
+
await config.writeJson(documentsPath(cwd), { documents }, { writer: WRITER });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reads everything the tab needs to render its status card: whether a
|
|
98
|
+
* Project Page exists to seed a root document from, the project's
|
|
99
|
+
* package.json (for the compatibility gate + default slug/version), the
|
|
100
|
+
* current document list, and any bundle/publish state already on disk.
|
|
101
|
+
*/
|
|
102
|
+
async function get({ cwd }) {
|
|
103
|
+
const realCwd = config.validatePath(cwd);
|
|
104
|
+
|
|
105
|
+
const marketingResult = await config.readText(
|
|
106
|
+
path.join(realCwd, 'session-manager-operations', 'project-pages', 'output', 'marketing.html'),
|
|
107
|
+
);
|
|
108
|
+
const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
|
|
109
|
+
const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : null;
|
|
110
|
+
|
|
111
|
+
const documents = (await readDocuments(realCwd)) ?? [];
|
|
112
|
+
|
|
113
|
+
const bundleManifestResult = await config.readJson(path.join(distDir(realCwd), 'manifest.json'));
|
|
114
|
+
const bundleManifest = bundleManifestResult.exists && !bundleManifestResult.parseError
|
|
115
|
+
? bundleManifestResult.data
|
|
116
|
+
: null;
|
|
117
|
+
|
|
118
|
+
const publishStateResult = await config.readJson(path.join(bilkoHostDir(realCwd), 'publish-state.json'));
|
|
119
|
+
const publishState = publishStateResult.exists && !publishStateResult.parseError
|
|
120
|
+
? publishStateResult.data
|
|
121
|
+
: null;
|
|
122
|
+
|
|
123
|
+
const slug = bundleManifest?.slug
|
|
124
|
+
?? deriveSlug(pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
hasMarketingPage: marketingResult.exists,
|
|
128
|
+
projectName: pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd),
|
|
129
|
+
packagePrivate: pkg ? pkg.private === true : false,
|
|
130
|
+
packageHomepage: pkg && typeof pkg.homepage === 'string' ? pkg.homepage : null,
|
|
131
|
+
packageVersion: pkg && typeof pkg.version === 'string' ? pkg.version : '0.0.0',
|
|
132
|
+
defaultSlug: deriveSlug(pkg && typeof pkg.name === 'string' ? pkg.name : path.basename(realCwd)),
|
|
133
|
+
documents: documents.map((d) => ({ ...d, url: documentUrl(slug, d.subpath) })),
|
|
134
|
+
// A rebuild is owed whenever the bundle on disk doesn't reflect the
|
|
135
|
+
// current document list — the tab uses this to gray out "up to date"
|
|
136
|
+
// vs. prompt "Prepare Bundle" again, and (post-publish) to warn that a
|
|
137
|
+
// removed document is still live until the next Publish.
|
|
138
|
+
bundleStale: bundleManifest ? bundleManifest.documentCount !== documents.length : documents.length > 0,
|
|
139
|
+
bundleManifest,
|
|
140
|
+
publishState,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Seeds documents.json with a single root document (the Marketing Project Page) if it doesn't exist yet. */
|
|
145
|
+
async function ensureSeededDocuments(cwd) {
|
|
146
|
+
const existing = await readDocuments(cwd);
|
|
147
|
+
if (existing) return existing;
|
|
148
|
+
const seeded = [{
|
|
149
|
+
id: crypto.randomUUID(),
|
|
150
|
+
subpath: '',
|
|
151
|
+
title: 'Project (Marketing)',
|
|
152
|
+
source: { kind: 'project-page-lens', lens: 'marketing' },
|
|
153
|
+
addedAt: new Date().toISOString(),
|
|
154
|
+
}];
|
|
155
|
+
await writeDocuments(cwd, seeded);
|
|
156
|
+
return seeded;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add a hosted document. `source` is either
|
|
161
|
+
* `{ kind: 'project-page-lens', lens: 'home'|'marketing'|'feature'|'architecture' }`
|
|
162
|
+
* (reuses an already-generated Project Page lens) or
|
|
163
|
+
* `{ kind: 'file', path: <repo-relative path> }` (any other local HTML —
|
|
164
|
+
* e.g. a HUMAN_LEARN page or a hand-authored explainer).
|
|
165
|
+
*/
|
|
166
|
+
async function addDocument({ cwd, subpath, title, source }) {
|
|
167
|
+
const realCwd = config.validatePath(cwd);
|
|
168
|
+
const subpathCheck = validateSubpath(subpath);
|
|
169
|
+
if (!subpathCheck.ok) throw new Error(subpathCheck.error);
|
|
170
|
+
if (source.kind === 'project-page-lens' && !PROJECT_PAGE_LENSES.has(source.lens)) {
|
|
171
|
+
throw new Error(`unknown Project Page lens: ${source.lens}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const documents = (await readDocuments(realCwd)) ?? await ensureSeededDocuments(realCwd);
|
|
175
|
+
if (subpath === '') {
|
|
176
|
+
throw new Error('the root document (subpath "") always exists — edit it instead of adding a second one');
|
|
177
|
+
}
|
|
178
|
+
if (documents.some((d) => d.subpath === subpath)) {
|
|
179
|
+
throw new Error(`a document already uses subpath "${subpath}"`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const next = [...documents, {
|
|
183
|
+
id: crypto.randomUUID(),
|
|
184
|
+
subpath,
|
|
185
|
+
title,
|
|
186
|
+
source,
|
|
187
|
+
addedAt: new Date().toISOString(),
|
|
188
|
+
}];
|
|
189
|
+
const setCheck = validateDocumentSet(next);
|
|
190
|
+
if (!setCheck.ok) throw new Error(setCheck.error);
|
|
191
|
+
|
|
192
|
+
await writeDocuments(realCwd, next);
|
|
193
|
+
return { documents: next };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Remove a hosted document (never the root). This only updates
|
|
198
|
+
* documents.json — the file stays in `dist/` (and, if already published,
|
|
199
|
+
* live on bilko.run) until the next Prepare Bundle + Publish, which is the
|
|
200
|
+
* confirmed mechanism that actually removes it (Bilko's publish tool
|
|
201
|
+
* rm -rf's + rebuilds the whole slug directory every publish).
|
|
202
|
+
*/
|
|
203
|
+
async function removeDocument({ cwd, id }) {
|
|
204
|
+
const realCwd = config.validatePath(cwd);
|
|
205
|
+
const documents = (await readDocuments(realCwd)) ?? [];
|
|
206
|
+
const target = documents.find((d) => d.id === id);
|
|
207
|
+
if (!target) throw new Error(`no document with id ${id}`);
|
|
208
|
+
if (target.subpath === '') throw new Error('cannot remove the root document');
|
|
209
|
+
|
|
210
|
+
const next = documents.filter((d) => d.id !== id);
|
|
211
|
+
await writeDocuments(realCwd, next);
|
|
212
|
+
return { documents: next };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function resolveDocumentHtml(cwd, doc) {
|
|
216
|
+
if (doc.source.kind === 'project-page-lens') {
|
|
217
|
+
const result = await config.readText(
|
|
218
|
+
path.join(cwd, 'session-manager-operations', 'project-pages', 'output', `${doc.source.lens}.html`),
|
|
219
|
+
);
|
|
220
|
+
if (!result.exists) {
|
|
221
|
+
throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
|
|
222
|
+
}
|
|
223
|
+
return result.text;
|
|
224
|
+
}
|
|
225
|
+
if (doc.source.kind === 'file') {
|
|
226
|
+
const abs = config.validatePath(path.join(cwd, doc.source.path));
|
|
227
|
+
const result = await config.readText(abs);
|
|
228
|
+
if (!result.exists) throw new Error(`document "${doc.title}"'s source file is missing: ${doc.source.path}`);
|
|
229
|
+
return result.text;
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`unknown document source kind: ${doc.source.kind}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Stage A: rebuild the ENTIRE dist/ tree from documents.json (rm + rewrite,
|
|
236
|
+
* mirroring Bilko's own full-replace publish semantics so local state can
|
|
237
|
+
* never lag the document list) + dist/manifest.json (host-contract manifest
|
|
238
|
+
* schema, one per bundle — golden.path/expect always point at the root
|
|
239
|
+
* document). Idempotent.
|
|
240
|
+
*/
|
|
241
|
+
async function prepareBundle({ cwd, slug }) {
|
|
242
|
+
const realCwd = config.validatePath(cwd);
|
|
243
|
+
const documents = await ensureSeededDocuments(realCwd);
|
|
244
|
+
|
|
245
|
+
const setCheck = validateDocumentSet(documents);
|
|
246
|
+
if (!setCheck.ok) throw new Error(setCheck.error);
|
|
247
|
+
|
|
248
|
+
const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
|
|
249
|
+
const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : {};
|
|
250
|
+
const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
251
|
+
|
|
252
|
+
const dist = distDir(realCwd);
|
|
253
|
+
await fsp.rm(dist, { recursive: true, force: true });
|
|
254
|
+
|
|
255
|
+
let totalGz = 0;
|
|
256
|
+
for (const doc of documents) {
|
|
257
|
+
const html = await resolveDocumentHtml(realCwd, doc);
|
|
258
|
+
const relPath = documentDistRelPath(doc.subpath);
|
|
259
|
+
await config.writeTextAtomic(path.join(dist, relPath), html, { writer: WRITER });
|
|
260
|
+
totalGz += gzipSync(Buffer.from(html, 'utf8')).length;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const { gitSha, gitBranch } = gitInfo(realCwd);
|
|
264
|
+
const goldenExpect = pkg && typeof pkg.name === 'string' ? pkg.name : slug;
|
|
265
|
+
|
|
266
|
+
const manifest = {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
slug,
|
|
269
|
+
version,
|
|
270
|
+
builtAt: new Date().toISOString(),
|
|
271
|
+
gitSha,
|
|
272
|
+
gitBranch,
|
|
273
|
+
hostKit: { version: '0.0.0' },
|
|
274
|
+
golden: { path: `/projects/${slug}/`, expect: goldenExpect },
|
|
275
|
+
health: {},
|
|
276
|
+
bundle: { sizeBytesGz: totalGz, fileCount: documents.length },
|
|
277
|
+
// Not part of the host-contract schema itself — this app's own staleness
|
|
278
|
+
// check (get()'s bundleStale) compares this against documents.length.
|
|
279
|
+
documentCount: documents.length,
|
|
280
|
+
documents: documents.map((d) => ({ subpath: d.subpath, title: d.title })),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
await config.writeJson(path.join(dist, 'manifest.json'), manifest, { writer: WRITER });
|
|
284
|
+
|
|
285
|
+
return { distPath: dist, manifest };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function registerBilkoHostIpc() {
|
|
289
|
+
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
290
|
+
ipcMain.handle('bilko-host:get', v(s.bilkoHostCwd, get));
|
|
291
|
+
ipcMain.handle('bilko-host:prepare-bundle', v(s.bilkoHostPrepareBundle, prepareBundle));
|
|
292
|
+
ipcMain.handle('bilko-host:add-document', v(s.bilkoHostAddDocument, addDocument));
|
|
293
|
+
ipcMain.handle('bilko-host:remove-document', v(s.bilkoHostRemoveDocument, removeDocument));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
registerBilkoHostIpc,
|
|
298
|
+
get,
|
|
299
|
+
prepareBundle,
|
|
300
|
+
addDocument,
|
|
301
|
+
removeDocument,
|
|
302
|
+
deriveSlug,
|
|
303
|
+
bilkoHostDir,
|
|
304
|
+
distDir,
|
|
305
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bilkoHostCore.cjs — pure logic for the "Host on Bilko.run" tab's
|
|
5
|
+
* multi-document bundle model. No fs/IPC here (that's bilkoHost.cjs) so
|
|
6
|
+
* this stays unit-testable without a real project directory.
|
|
7
|
+
*
|
|
8
|
+
* Model: ONE Bilko project (one registry slug, one `dist/` tree) can host
|
|
9
|
+
* MANY documents — exactly one root document (subpath `''`, becomes
|
|
10
|
+
* dist/index.html — "the project itself") plus any number of sub-path
|
|
11
|
+
* documents (subpath 'special-doc/01', becomes dist/special-doc/01/
|
|
12
|
+
* index.html → bilko.run/projects/<slug>/special-doc/01/). This mirrors how
|
|
13
|
+
* Bilko's own publish_static_project already works: it `rm -rf`s then
|
|
14
|
+
* `cp -r`s the WHOLE public/projects/<slug>/ tree on every publish, so
|
|
15
|
+
* removing a document locally and re-publishing is guaranteed to remove it
|
|
16
|
+
* live too — no separate per-document delete API needed on Bilko's side.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SUBPATH_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
20
|
+
const MAX_SEGMENTS = 6;
|
|
21
|
+
const MAX_SUBPATH_LENGTH = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validates a document's subpath. `''` (root) is always valid. Otherwise
|
|
25
|
+
* every '/'-separated segment must be lowercase kebab-case, no '.'/'..',
|
|
26
|
+
* no leading/trailing/double slashes, bounded depth and length.
|
|
27
|
+
*/
|
|
28
|
+
function validateSubpath(subpath) {
|
|
29
|
+
if (typeof subpath !== 'string') return { ok: false, error: 'subpath must be a string' };
|
|
30
|
+
if (subpath === '') return { ok: true, error: null };
|
|
31
|
+
if (subpath.length > MAX_SUBPATH_LENGTH) return { ok: false, error: 'subpath is too long' };
|
|
32
|
+
if (subpath.startsWith('/') || subpath.endsWith('/')) {
|
|
33
|
+
return { ok: false, error: 'subpath must not start or end with /' };
|
|
34
|
+
}
|
|
35
|
+
const segments = subpath.split('/');
|
|
36
|
+
if (segments.length > MAX_SEGMENTS) return { ok: false, error: `subpath has more than ${MAX_SEGMENTS} segments` };
|
|
37
|
+
for (const seg of segments) {
|
|
38
|
+
if (!SUBPATH_SEGMENT.test(seg)) {
|
|
39
|
+
return { ok: false, error: `invalid subpath segment "${seg}" — use lowercase kebab-case` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, error: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validates a full document list: subpaths individually valid, exactly one
|
|
47
|
+
* root document, no duplicate subpaths. Pure — takes the array shape
|
|
48
|
+
* documents.json stores, `[{ id, subpath, ... }]`.
|
|
49
|
+
*/
|
|
50
|
+
function validateDocumentSet(documents) {
|
|
51
|
+
if (!Array.isArray(documents) || documents.length === 0) {
|
|
52
|
+
return { ok: false, error: 'at least one document (the root) is required' };
|
|
53
|
+
}
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let rootCount = 0;
|
|
56
|
+
for (const doc of documents) {
|
|
57
|
+
const v = validateSubpath(doc.subpath);
|
|
58
|
+
if (!v.ok) return { ok: false, error: `document "${doc.id}": ${v.error}` };
|
|
59
|
+
if (seen.has(doc.subpath)) return { ok: false, error: `duplicate subpath "${doc.subpath}"` };
|
|
60
|
+
seen.add(doc.subpath);
|
|
61
|
+
if (doc.subpath === '') rootCount += 1;
|
|
62
|
+
}
|
|
63
|
+
if (rootCount !== 1) return { ok: false, error: 'exactly one document must have subpath "" (the project root)' };
|
|
64
|
+
return { ok: true, error: null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where a document's rendered HTML lands inside dist/. Pure path math. */
|
|
68
|
+
function documentDistRelPath(subpath) {
|
|
69
|
+
return subpath === '' ? 'index.html' : `${subpath}/index.html`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The bilko.run URL a document resolves to once published, given the
|
|
74
|
+
* project's slug. Pure — used by the tab to render "will be live at" links
|
|
75
|
+
* before anything is actually published.
|
|
76
|
+
*/
|
|
77
|
+
function documentUrl(slug, subpath) {
|
|
78
|
+
return subpath === '' ? `https://bilko.run/projects/${slug}/` : `https://bilko.run/projects/${slug}/${subpath}/`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
SUBPATH_SEGMENT,
|
|
83
|
+
MAX_SEGMENTS,
|
|
84
|
+
MAX_SUBPATH_LENGTH,
|
|
85
|
+
validateSubpath,
|
|
86
|
+
validateDocumentSet,
|
|
87
|
+
documentDistRelPath,
|
|
88
|
+
documentUrl,
|
|
89
|
+
};
|
package/src/main/config.cjs
CHANGED
|
@@ -136,14 +136,6 @@ function validateWrite(realAbs) {
|
|
|
136
136
|
if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
|
-
// Feedback inbox writes (scheduler RCA hook — rcaFeedbackHook.cjs — and
|
|
140
|
-
// any interactive feedback filing): narrowly scoped to
|
|
141
|
-
// session-manager-operations/feedback/, this repo's existing
|
|
142
|
-
// per-project intake convention.
|
|
143
|
-
const feedbackSub = path.join(realRoot, 'session-manager-operations', 'feedback');
|
|
144
|
-
if (realAbs === feedbackSub || realAbs.startsWith(feedbackSub + path.sep)) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
139
|
// PromptSession persistence (active-index.json + per-session archives,
|
|
148
140
|
// promptSessions.ts) and the scheduler's own read-modify-write of that
|
|
149
141
|
// same active index (promptSessionEvents.cjs, PRD 814) — narrowly
|
|
@@ -170,6 +162,14 @@ function validateWrite(realAbs) {
|
|
|
170
162
|
if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
|
|
171
163
|
return;
|
|
172
164
|
}
|
|
165
|
+
// Host on Bilko.run bundle prep (bilkoHost.cjs's prepareBundle/
|
|
166
|
+
// addDocument/removeDocument): narrowly scoped to
|
|
167
|
+
// session-manager-operations/bilko-host/, this repo's existing
|
|
168
|
+
// per-project artifact-store convention.
|
|
169
|
+
const bilkoHostSub = path.join(realRoot, 'session-manager-operations', 'bilko-host');
|
|
170
|
+
if (realAbs === bilkoHostSub || realAbs.startsWith(bilkoHostSub + path.sep)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|
package/src/main/index.cjs
CHANGED
|
@@ -62,6 +62,7 @@ const memoryTool = require('./memoryTool.cjs');
|
|
|
62
62
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
63
63
|
const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
64
64
|
const { registerProjectPagesIpc } = require('./projectPages.cjs');
|
|
65
|
+
const { registerBilkoHostIpc } = require('./bilkoHost.cjs');
|
|
65
66
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
66
67
|
const agentMemory = require('./agentMemory.cjs');
|
|
67
68
|
const git = require('./git.cjs');
|
|
@@ -821,6 +822,7 @@ activeIndexMerge.registerActiveIndexMergeHandlers();
|
|
|
821
822
|
registerMemoryAggregateIpc();
|
|
822
823
|
registerProjectBriefIpc();
|
|
823
824
|
registerProjectPagesIpc();
|
|
825
|
+
registerBilkoHostIpc();
|
|
824
826
|
ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
|
|
825
827
|
const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
|
|
826
828
|
return { ok };
|