claude-code-session-manager 0.76.0 → 0.77.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/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedulerMcpServerProjectHome.test.cjs — the four project_home_* MCP
|
|
3
|
+
* tools (PRD: project-home-mcp-tools) that wrap PRD 1089's admin routes.
|
|
4
|
+
* Covers: presence in TOOLS, catalog-composed descriptions, correct
|
|
5
|
+
* route/payload dispatch via a stubbed admin HTTP server, cwd defaulting to
|
|
6
|
+
* SM_PROJECT_ROOT/process.cwd() when omitted, the app-not-running failure
|
|
7
|
+
* shape, and that project_home_get_contract's composed response is
|
|
8
|
+
* self-sufficient (mentions all 5 lens ids, no repo-relative path).
|
|
9
|
+
*
|
|
10
|
+
* Same HOME-repointing pattern as schedulerMcpServerHelp.test.cjs — see that
|
|
11
|
+
* file's header for why.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, afterEach } from 'vitest';
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const http = require('node:http');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const SERVER_PATH = path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs');
|
|
24
|
+
const { MCP_TOOL_CATALOG, composeDescription } = require('../mcpToolCatalog.cjs');
|
|
25
|
+
|
|
26
|
+
const PROJECT_HOME_TOOL_NAMES = [
|
|
27
|
+
'project_home_get_contract',
|
|
28
|
+
'project_home_validate_summary',
|
|
29
|
+
'project_home_render',
|
|
30
|
+
'project_home_status',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const tmpDirs = [];
|
|
34
|
+
const servers = [];
|
|
35
|
+
let originalHome;
|
|
36
|
+
let originalProjectRoot;
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
if (originalHome !== undefined) {
|
|
40
|
+
process.env.HOME = originalHome;
|
|
41
|
+
originalHome = undefined;
|
|
42
|
+
}
|
|
43
|
+
if (originalProjectRoot !== undefined) {
|
|
44
|
+
if (originalProjectRoot === null) delete process.env.SM_PROJECT_ROOT;
|
|
45
|
+
else process.env.SM_PROJECT_ROOT = originalProjectRoot;
|
|
46
|
+
originalProjectRoot = undefined;
|
|
47
|
+
}
|
|
48
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
49
|
+
while (servers.length) {
|
|
50
|
+
const s = servers.pop();
|
|
51
|
+
await new Promise((resolve) => s.close(resolve));
|
|
52
|
+
}
|
|
53
|
+
while (tmpDirs.length) {
|
|
54
|
+
const d = tmpDirs.pop();
|
|
55
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function mkTmp() {
|
|
60
|
+
const d = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mcp-project-home-'));
|
|
61
|
+
tmpDirs.push(d);
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Starts a fake admin HTTP server that records every request and answers with `body`. */
|
|
66
|
+
function startFakeAdminServer(token, body) {
|
|
67
|
+
const requests = [];
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const server = http.createServer((req, res) => {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
req.on('data', (c) => chunks.push(c));
|
|
72
|
+
req.on('end', () => {
|
|
73
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
74
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
75
|
+
res.end(JSON.stringify({ ok: false, error: 'bad token' }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
79
|
+
requests.push({
|
|
80
|
+
method: req.method,
|
|
81
|
+
url: req.url,
|
|
82
|
+
body: raw ? JSON.parse(raw) : undefined,
|
|
83
|
+
});
|
|
84
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
85
|
+
res.end(JSON.stringify(body));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
server.listen(0, '127.0.0.1', () => resolve({ server, requests }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireServerWithHome(homeDir) {
|
|
93
|
+
originalHome = process.env.HOME;
|
|
94
|
+
process.env.HOME = homeDir;
|
|
95
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
96
|
+
const mod = require(SERVER_PATH);
|
|
97
|
+
process.env.HOME = originalHome;
|
|
98
|
+
originalHome = undefined;
|
|
99
|
+
return mod;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function writeAdminConfig(homeDir, { port, token }) {
|
|
103
|
+
const tokenPath = path.join(homeDir, '.claude', 'session-manager', 'admin-api.json');
|
|
104
|
+
await fsp.mkdir(path.dirname(tokenPath), { recursive: true });
|
|
105
|
+
await fsp.writeFile(tokenPath, JSON.stringify({ port, token }), 'utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function callTool(handleCallTool, name, args) {
|
|
109
|
+
return handleCallTool({ params: { name, arguments: args } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
test('all four project_home_* tools are present in TOOLS with correct input schemas', async () => {
|
|
113
|
+
const homeDir = await mkTmp();
|
|
114
|
+
const { TOOLS } = requireServerWithHome(homeDir);
|
|
115
|
+
for (const name of PROJECT_HOME_TOOL_NAMES) {
|
|
116
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
117
|
+
expect(tool).toBeTruthy();
|
|
118
|
+
expect(tool.inputSchema.type).toBe('object');
|
|
119
|
+
expect(tool.inputSchema.properties.cwd).toBeTruthy();
|
|
120
|
+
// cwd is never required on any of the four
|
|
121
|
+
expect(tool.inputSchema.required ?? []).not.toContain('cwd');
|
|
122
|
+
}
|
|
123
|
+
const validate = TOOLS.find((t) => t.name === 'project_home_validate_summary');
|
|
124
|
+
expect(validate.inputSchema.required).toContain('summary');
|
|
125
|
+
const render = TOOLS.find((t) => t.name === 'project_home_render');
|
|
126
|
+
expect(render.inputSchema.required).toEqual(expect.arrayContaining(['summary', 'picks']));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test.each(PROJECT_HOME_TOOL_NAMES)('%s description equals the catalog-composed string', async (name) => {
|
|
130
|
+
const homeDir = await mkTmp();
|
|
131
|
+
const { TOOLS } = requireServerWithHome(homeDir);
|
|
132
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
133
|
+
const entry = MCP_TOOL_CATALOG.find((e) => e.name === name);
|
|
134
|
+
expect(entry).toBeTruthy();
|
|
135
|
+
expect(entry.group).toBe('project-home');
|
|
136
|
+
expect(tool.description).toBe(composeDescription(entry));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('project_home_get_contract dispatches GET /admin/project-home/contract with cwd query param', async () => {
|
|
140
|
+
const homeDir = await mkTmp();
|
|
141
|
+
const token = 'test-token';
|
|
142
|
+
const { server, requests } = await startFakeAdminServer(token, {
|
|
143
|
+
ok: true,
|
|
144
|
+
protocol: ['step one'],
|
|
145
|
+
catalog: { lenses: [{ id: 'home' }, { id: 'marketing' }, { id: 'feature' }, { id: 'architecture' }, { id: 'brief' }] },
|
|
146
|
+
paths: { summaryPath: '/abs/summary.json', picksPath: '/abs/picks.json', outputDir: '/abs/output' },
|
|
147
|
+
spec: { text: 'spec text', path: '/abs/spec.md' },
|
|
148
|
+
});
|
|
149
|
+
servers.push(server);
|
|
150
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
151
|
+
|
|
152
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
153
|
+
const result = await callTool(handleCallTool, 'project_home_get_contract', { cwd: '/home/bilko/Projects/session-manager' });
|
|
154
|
+
expect(result.isError).toBeFalsy();
|
|
155
|
+
expect(requests).toHaveLength(1);
|
|
156
|
+
expect(requests[0].method).toBe('GET');
|
|
157
|
+
expect(requests[0].url).toBe('/admin/project-home/contract?cwd=%2Fhome%2Fbilko%2FProjects%2Fsession-manager');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('project_home_get_contract composed response mentions all 5 lens ids and no repo-relative path', async () => {
|
|
161
|
+
const homeDir = await mkTmp();
|
|
162
|
+
const token = 'test-token';
|
|
163
|
+
const { server } = await startFakeAdminServer(token, {
|
|
164
|
+
ok: true,
|
|
165
|
+
protocol: [
|
|
166
|
+
'Call GET /admin/project-home/status?cwd=<abs> first.',
|
|
167
|
+
'Compose a ProjectPageSummary matching summarySchema.',
|
|
168
|
+
'For each lens in catalog.lenses, pick a variant per slot.',
|
|
169
|
+
'POST /admin/project-home/validate-summary with {cwd, summary}.',
|
|
170
|
+
'POST /admin/project-home/render with {cwd, summary, picks}.',
|
|
171
|
+
],
|
|
172
|
+
summarySchema: { type: 'object' },
|
|
173
|
+
picksSchema: { type: 'object' },
|
|
174
|
+
catalog: {
|
|
175
|
+
lenses: [
|
|
176
|
+
{ id: 'home', slots: [] },
|
|
177
|
+
{ id: 'marketing', slots: [] },
|
|
178
|
+
{ id: 'feature', slots: [] },
|
|
179
|
+
{ id: 'architecture', slots: [] },
|
|
180
|
+
{ id: 'brief', slots: [] },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
paths: { summaryPath: '/abs/project-pages/summary.json', picksPath: '/abs/project-pages/picks.json', outputDir: '/abs/project-pages/output' },
|
|
184
|
+
spec: { text: 'This project uses absolute paths only, never repo-relative ones.', path: '/abs/project-pages-pipeline.md' },
|
|
185
|
+
});
|
|
186
|
+
servers.push(server);
|
|
187
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
188
|
+
|
|
189
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
190
|
+
const result = await callTool(handleCallTool, 'project_home_get_contract', {});
|
|
191
|
+
expect(result.isError).toBeFalsy();
|
|
192
|
+
const text = result.content[0].text;
|
|
193
|
+
for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
|
|
194
|
+
expect(text).toContain(lens);
|
|
195
|
+
}
|
|
196
|
+
expect(text).not.toMatch(/session-manager-operations\/architecture\//);
|
|
197
|
+
expect(text).not.toMatch(/\.claude\/agents\//);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('project_home_validate_summary dispatches POST /admin/project-home/validate-summary with cwd+summary', async () => {
|
|
201
|
+
const homeDir = await mkTmp();
|
|
202
|
+
const token = 'test-token';
|
|
203
|
+
const { server, requests } = await startFakeAdminServer(token, { ok: true, valid: true, errors: [] });
|
|
204
|
+
servers.push(server);
|
|
205
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
206
|
+
|
|
207
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
208
|
+
const summary = { title: 'Example Project' };
|
|
209
|
+
const result = await callTool(handleCallTool, 'project_home_validate_summary', {
|
|
210
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
211
|
+
summary,
|
|
212
|
+
});
|
|
213
|
+
expect(result.isError).toBeFalsy();
|
|
214
|
+
expect(requests).toHaveLength(1);
|
|
215
|
+
expect(requests[0].method).toBe('POST');
|
|
216
|
+
expect(requests[0].url).toBe('/admin/project-home/validate-summary');
|
|
217
|
+
expect(requests[0].body).toEqual({ cwd: '/home/bilko/Projects/session-manager', summary });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('project_home_validate_summary surfaces per-field errors from the route, not a generic failure', async () => {
|
|
221
|
+
const homeDir = await mkTmp();
|
|
222
|
+
const token = 'test-token';
|
|
223
|
+
const { server } = await startFakeAdminServer(token, {
|
|
224
|
+
ok: true,
|
|
225
|
+
valid: false,
|
|
226
|
+
errors: [{ field: 'quotes[0].attribution', message: 'quotes[0].attribution must not be a placeholder value' }],
|
|
227
|
+
});
|
|
228
|
+
servers.push(server);
|
|
229
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
230
|
+
|
|
231
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
232
|
+
const result = await callTool(handleCallTool, 'project_home_validate_summary', {
|
|
233
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
234
|
+
summary: { title: 'x', quotes: [{ attribution: 'TODO' }] },
|
|
235
|
+
});
|
|
236
|
+
expect(result.isError).toBeFalsy();
|
|
237
|
+
const body = JSON.parse(result.content[0].text);
|
|
238
|
+
expect(body.valid).toBe(false);
|
|
239
|
+
expect(body.errors).toEqual([
|
|
240
|
+
{ field: 'quotes[0].attribution', message: 'quotes[0].attribution must not be a placeholder value' },
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('project_home_validate_summary requires summary', async () => {
|
|
245
|
+
const homeDir = await mkTmp();
|
|
246
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
247
|
+
const result = await callTool(handleCallTool, 'project_home_validate_summary', { cwd: '/x' });
|
|
248
|
+
expect(result.isError).toBe(true);
|
|
249
|
+
expect(result.content[0].text).toContain('summary');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('project_home_render dispatches POST /admin/project-home/render with cwd+summary+picks', async () => {
|
|
253
|
+
const homeDir = await mkTmp();
|
|
254
|
+
const token = 'test-token';
|
|
255
|
+
const { server, requests } = await startFakeAdminServer(token, {
|
|
256
|
+
ok: true,
|
|
257
|
+
filesWritten: ['/abs/summary.json'],
|
|
258
|
+
generatedAt: '2026-09-01T00:00:00.000Z',
|
|
259
|
+
});
|
|
260
|
+
servers.push(server);
|
|
261
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
262
|
+
|
|
263
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
264
|
+
const summary = { title: 'Example Project' };
|
|
265
|
+
const picks = { home: { hero: 'variant-a' } };
|
|
266
|
+
const result = await callTool(handleCallTool, 'project_home_render', {
|
|
267
|
+
cwd: '/home/bilko/Projects/session-manager',
|
|
268
|
+
summary,
|
|
269
|
+
picks,
|
|
270
|
+
});
|
|
271
|
+
expect(result.isError).toBeFalsy();
|
|
272
|
+
expect(requests).toHaveLength(1);
|
|
273
|
+
expect(requests[0].method).toBe('POST');
|
|
274
|
+
expect(requests[0].url).toBe('/admin/project-home/render');
|
|
275
|
+
expect(requests[0].body).toEqual({ cwd: '/home/bilko/Projects/session-manager', summary, picks });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('project_home_render requires both summary and picks', async () => {
|
|
279
|
+
const homeDir = await mkTmp();
|
|
280
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
281
|
+
|
|
282
|
+
const missingSummary = await callTool(handleCallTool, 'project_home_render', { picks: {} });
|
|
283
|
+
expect(missingSummary.isError).toBe(true);
|
|
284
|
+
expect(missingSummary.content[0].text).toContain('summary');
|
|
285
|
+
|
|
286
|
+
const missingPicks = await callTool(handleCallTool, 'project_home_render', { summary: {} });
|
|
287
|
+
expect(missingPicks.isError).toBe(true);
|
|
288
|
+
expect(missingPicks.content[0].text).toContain('picks');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('project_home_status dispatches GET /admin/project-home/status with cwd query param', async () => {
|
|
292
|
+
const homeDir = await mkTmp();
|
|
293
|
+
const token = 'test-token';
|
|
294
|
+
const { server, requests } = await startFakeAdminServer(token, {
|
|
295
|
+
ok: true,
|
|
296
|
+
summary: { exists: false, mtimeMs: null },
|
|
297
|
+
picks: { exists: false, mtimeMs: null },
|
|
298
|
+
output: {
|
|
299
|
+
home: { exists: false, mtimeMs: null },
|
|
300
|
+
marketing: { exists: false, mtimeMs: null },
|
|
301
|
+
feature: { exists: false, mtimeMs: null },
|
|
302
|
+
architecture: { exists: false, mtimeMs: null },
|
|
303
|
+
brief: { exists: false, mtimeMs: null },
|
|
304
|
+
},
|
|
305
|
+
manifest: { exists: false, generatedAt: null },
|
|
306
|
+
});
|
|
307
|
+
servers.push(server);
|
|
308
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
309
|
+
|
|
310
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
311
|
+
const result = await callTool(handleCallTool, 'project_home_status', { cwd: '/home/bilko/Projects/session-manager' });
|
|
312
|
+
expect(result.isError).toBeFalsy();
|
|
313
|
+
expect(requests).toHaveLength(1);
|
|
314
|
+
expect(requests[0].method).toBe('GET');
|
|
315
|
+
expect(requests[0].url).toBe('/admin/project-home/status?cwd=%2Fhome%2Fbilko%2FProjects%2Fsession-manager');
|
|
316
|
+
|
|
317
|
+
// Never-generated project: well-formed all-absent result, not an error.
|
|
318
|
+
const body = JSON.parse(result.content[0].text);
|
|
319
|
+
expect(body.ok).toBe(true);
|
|
320
|
+
expect(body.summary.exists).toBe(false);
|
|
321
|
+
expect(body.manifest.exists).toBe(false);
|
|
322
|
+
expect(body.output.brief.exists).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test.each(PROJECT_HOME_TOOL_NAMES)('%s defaults cwd to SM_PROJECT_ROOT when omitted', async (name) => {
|
|
326
|
+
const homeDir = await mkTmp();
|
|
327
|
+
const token = 'test-token';
|
|
328
|
+
const { server, requests } = await startFakeAdminServer(token, { ok: true, valid: true, errors: [] });
|
|
329
|
+
servers.push(server);
|
|
330
|
+
await writeAdminConfig(homeDir, { port: server.address().port, token });
|
|
331
|
+
|
|
332
|
+
originalProjectRoot = process.env.SM_PROJECT_ROOT ?? null;
|
|
333
|
+
process.env.SM_PROJECT_ROOT = '/home/bilko/Projects/session-manager/session-manager-operations/scheduler/epics/some-epic/worktree';
|
|
334
|
+
|
|
335
|
+
const { handleCallTool } = requireServerWithHome(homeDir);
|
|
336
|
+
const args = name === 'project_home_render' ? { summary: {}, picks: {} } : name === 'project_home_validate_summary' ? { summary: {} } : {};
|
|
337
|
+
await callTool(handleCallTool, name, args);
|
|
338
|
+
expect(requests).toHaveLength(1);
|
|
339
|
+
const encoded = requests[0].body?.cwd ?? new URLSearchParams(requests[0].url.split('?')[1]).get('cwd');
|
|
340
|
+
expect(encoded).toBe(process.env.SM_PROJECT_ROOT);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test.each(PROJECT_HOME_TOOL_NAMES)('%s returns the app-not-running error when the admin API is unreachable', async (name) => {
|
|
344
|
+
const homeDir = await mkTmp();
|
|
345
|
+
const { handleCallTool, NOT_RUNNING_ERROR } = requireServerWithHome(homeDir);
|
|
346
|
+
const args = name === 'project_home_render' ? { summary: {}, picks: {} } : name === 'project_home_validate_summary' ? { summary: {} } : {};
|
|
347
|
+
const result = await callTool(handleCallTool, name, args);
|
|
348
|
+
expect(result.isError).toBe(true);
|
|
349
|
+
expect(result.content[0].text).toBe(NOT_RUNNING_ERROR);
|
|
350
|
+
});
|
|
@@ -120,9 +120,67 @@ function resolveEpicModel({ cwd, claudeSessionId, fallbackModel = FALLBACK_MODEL
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** Mirrors epicIntake.ts's AGENT_BODY_CHAR_CAP — a runaway persona file must
|
|
124
|
+
* not be able to dominate a spawned job's system prompt either. Duplicated
|
|
125
|
+
* rather than imported: epicIntake.ts is a renderer module (no ES modules
|
|
126
|
+
* in main). */
|
|
127
|
+
const AGENT_BODY_CHAR_CAP = 6000;
|
|
128
|
+
|
|
129
|
+
/** Trims a persona's frontmatter-stripped body and caps it at
|
|
130
|
+
* AGENT_BODY_CHAR_CAP, appending a truncation notice naming `personaPath`
|
|
131
|
+
* — same shape as epicIntake.ts's buildPersonaBodyText. Returns null for an
|
|
132
|
+
* empty body. */
|
|
133
|
+
function buildPersonaBodyText(rawBody, personaPath) {
|
|
134
|
+
const body = (rawBody || '').trim();
|
|
135
|
+
if (!body) return null;
|
|
136
|
+
if (body.length <= AGENT_BODY_CHAR_CAP) return body;
|
|
137
|
+
const truncated = body.slice(0, AGENT_BODY_CHAR_CAP);
|
|
138
|
+
const pathLabel = personaPath || 'the persona file';
|
|
139
|
+
return `${truncated}\n\n[Truncated — ${pathLabel} exceeds ${AGENT_BODY_CHAR_CAP} characters; see the file for the full body.]`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the persona system-prompt + model a scheduled PRD's `agentType`
|
|
144
|
+
* should launch its headless `claude -p` spawn with. Never throws — a
|
|
145
|
+
* missing agentType, or one that no longer resolves to a readable persona
|
|
146
|
+
* file (renamed/deleted), falls back to `{ systemPrompt: null, model:
|
|
147
|
+
* fallbackModel, personaPath: null }` and logs once via prdAgentType.cjs's
|
|
148
|
+
* reportDanglingAgentTypeOnce (the PRD-frontmatter FK's own dangling-read
|
|
149
|
+
* convention) — a dangling persona must never park or fail the job.
|
|
150
|
+
*
|
|
151
|
+
* Reuses agentLibrary.cjs's getPersonaBody (project-overlay-then-global
|
|
152
|
+
* read, same precedence resolvePersonaPaths documents) rather than a second
|
|
153
|
+
* persona-file reader, and this module's own frontmatter-stripped-body cap
|
|
154
|
+
* (mirroring epicIntake.ts's Epic-path treatment) so the PRD path gets the
|
|
155
|
+
* same runaway-file protection.
|
|
156
|
+
*
|
|
157
|
+
* @param {{ cwd: string, agentType: string|null, fallbackModel?: string, deps?: object }} opts
|
|
158
|
+
* @returns {Promise<{ model: string, systemPrompt: string|null, personaPath: string|null }>}
|
|
159
|
+
*/
|
|
160
|
+
async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
|
|
161
|
+
const miss = { model: fallbackModel, systemPrompt: null, personaPath: null };
|
|
162
|
+
if (!agentType) return miss;
|
|
163
|
+
try {
|
|
164
|
+
const getBody = deps.getPersonaBody || require('../agentLibrary.cjs').getPersonaBody;
|
|
165
|
+
const persona = await getBody({ cwd, name: agentType });
|
|
166
|
+
if (!persona) {
|
|
167
|
+
const reportOnce = deps.reportDanglingAgentTypeOnce || require('./prdAgentType.cjs').reportDanglingAgentTypeOnce;
|
|
168
|
+
reportOnce(cwd, agentType, deps);
|
|
169
|
+
return miss;
|
|
170
|
+
}
|
|
171
|
+
const { fm, body } = splitFrontmatter(persona.text);
|
|
172
|
+
const model = fm.model && fm.model !== 'inherit' ? fm.model : fallbackModel;
|
|
173
|
+
const systemPrompt = buildPersonaBodyText(body, persona.path);
|
|
174
|
+
return { model, systemPrompt, personaPath: persona.path };
|
|
175
|
+
} catch {
|
|
176
|
+
return miss;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
123
180
|
module.exports = {
|
|
124
181
|
FALLBACK_MODEL,
|
|
125
182
|
resolveEpicModel,
|
|
126
183
|
findAgentTypeByClaudeSessionId,
|
|
127
184
|
readPersonaModel,
|
|
185
|
+
resolvePrdPersonaForSpawn,
|
|
128
186
|
};
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
* killedByWatchdog — string | null
|
|
41
41
|
* error — Error | undefined (child 'error' event or sync spawn throw)
|
|
42
42
|
* spawnFailed — boolean, true when the error came from a synchronous spawn throw
|
|
43
|
+
* leakedDescendants — { pid, pcpu, etimes, comm }[], the process-group survivors
|
|
44
|
+
* sweepChildProcessGroup found still alive right before it
|
|
45
|
+
* swept them. [] in the normal case (nothing leaked, or
|
|
46
|
+
* non-Linux, or enumeration failed). See sweepChildProcessGroup.
|
|
43
47
|
* safeLog — same safeLog (usable inside onExit for final log lines)
|
|
44
48
|
*
|
|
45
49
|
* Returns { child, cancel } where cancel() clears all timers + SIGKILLs.
|
|
@@ -47,7 +51,7 @@
|
|
|
47
51
|
*/
|
|
48
52
|
|
|
49
53
|
const fs = require('node:fs');
|
|
50
|
-
const { spawn } = require('node:child_process');
|
|
54
|
+
const { spawn, execFileSync } = require('node:child_process');
|
|
51
55
|
|
|
52
56
|
// Grace period between the post-exit SIGTERM sweep and the follow-up SIGKILL
|
|
53
57
|
// sweep of a detached child's process group. See sweepChildProcessGroup below.
|
|
@@ -136,11 +140,39 @@ function openLog(logPath) {
|
|
|
136
140
|
// a job's descendants must not outlive the job. Killing the job's own process
|
|
137
141
|
// group at the job's own exit is ownership-based and needs no /proc heuristics
|
|
138
142
|
// to distinguish an abandoned descendant from a live one.
|
|
143
|
+
// Enumerate the still-alive members of process group `pgid` (Linux only).
|
|
144
|
+
// Read-only — never throws, never blocks the caller's exit path. Used to
|
|
145
|
+
// report what sweepChildProcessGroup is about to kill, since the kill itself
|
|
146
|
+
// is silent (ESRCH on the normal empty-group case).
|
|
147
|
+
function enumerateProcessGroupSurvivors(pgid) {
|
|
148
|
+
if (process.platform !== 'linux') return [];
|
|
149
|
+
if (!pgid || pgid <= 1) return [];
|
|
150
|
+
try {
|
|
151
|
+
const out = execFileSync('ps', ['-eo', 'pid,pgrp,pcpu,etimes,comm'], { encoding: 'utf8', timeout: 2000 });
|
|
152
|
+
const survivors = [];
|
|
153
|
+
for (const line of out.split('\n').slice(1)) {
|
|
154
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(.+)$/);
|
|
155
|
+
if (!m) continue;
|
|
156
|
+
const [, pidStr, pgrpStr, pcpuStr, etimesStr, comm] = m;
|
|
157
|
+
if (Number(pgrpStr) !== pgid) continue;
|
|
158
|
+
survivors.push({ pid: Number(pidStr), pcpu: Number(pcpuStr), etimes: Number(etimesStr), comm });
|
|
159
|
+
}
|
|
160
|
+
return survivors;
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Returns the leaked descendants found (see enumerateProcessGroupSurvivors),
|
|
167
|
+
// having enumerated them BEFORE issuing SIGTERM so the report reflects what
|
|
168
|
+
// was actually swept, not what happened to still be running afterward.
|
|
139
169
|
function sweepChildProcessGroup(child, detached) {
|
|
140
|
-
if (!detached) return;
|
|
141
|
-
if (!child || !child.pid || child.pid <= 1) return;
|
|
170
|
+
if (!detached) return [];
|
|
171
|
+
if (!child || !child.pid || child.pid <= 1) return [];
|
|
142
172
|
// Defensive: never target the Session Manager process's own group.
|
|
143
|
-
if (child.pid === process.pid) return;
|
|
173
|
+
if (child.pid === process.pid) return [];
|
|
174
|
+
|
|
175
|
+
const leaked = enumerateProcessGroupSurvivors(child.pid).filter((p) => p.pid !== child.pid);
|
|
144
176
|
|
|
145
177
|
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
|
|
146
178
|
|
|
@@ -148,6 +180,8 @@ function sweepChildProcessGroup(child, detached) {
|
|
|
148
180
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
|
|
149
181
|
}, POST_EXIT_GROUP_SWEEP_GRACE_MS);
|
|
150
182
|
if (t.unref) t.unref();
|
|
183
|
+
|
|
184
|
+
return leaked;
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
|
|
@@ -204,6 +238,7 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
|
|
|
204
238
|
// calls onExit (caller may still safeLog inside), then closes the fd.
|
|
205
239
|
const handleDone = (exitCode, signal, error, spawnFailed) => {
|
|
206
240
|
clearAllTimers();
|
|
241
|
+
const leakedDescendants = sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
|
|
207
242
|
if (onExit) {
|
|
208
243
|
onExit({
|
|
209
244
|
exitCode,
|
|
@@ -211,10 +246,10 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
|
|
|
211
246
|
killedByWatchdog: ctx.killedByWatchdog,
|
|
212
247
|
error,
|
|
213
248
|
spawnFailed: spawnFailed ?? false,
|
|
249
|
+
leakedDescendants,
|
|
214
250
|
safeLog,
|
|
215
251
|
});
|
|
216
252
|
}
|
|
217
|
-
sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
|
|
218
253
|
closeFd();
|
|
219
254
|
};
|
|
220
255
|
|
|
@@ -39,4 +39,57 @@ function resolveClaudeBin() {
|
|
|
39
39
|
return cached;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
// ---------- version probe ----------
|
|
43
|
+
//
|
|
44
|
+
// `claude --version`, cached. The scheduler's launch circuit breaker
|
|
45
|
+
// (lib/launchFailure.cjs) records the CLI version a launch block was armed
|
|
46
|
+
// under and drops the block the moment the version changes — the incident
|
|
47
|
+
// behind it (issue #11: an outdated CLI sending a thinking parameter the
|
|
48
|
+
// model rejects) is fixed by `claude update`, and the queue must notice
|
|
49
|
+
// that without a human pressing anything. Async, bounded, never throws:
|
|
50
|
+
// a probe failure yields null and the breaker simply loses the
|
|
51
|
+
// version-change shortcut (backoff still recovers).
|
|
52
|
+
const VERSION_TTL_MS = 5 * 60_000;
|
|
53
|
+
const VERSION_PROBE_TIMEOUT_MS = 8_000;
|
|
54
|
+
let versionCache = { at: 0, value: null, inflight: null };
|
|
55
|
+
|
|
56
|
+
function probeClaudeVersion({ now = Date.now(), execFileImpl } = {}) {
|
|
57
|
+
// Operator/test pin: skips executing the binary entirely.
|
|
58
|
+
if (process.env.SM_CLAUDE_VERSION) return Promise.resolve(process.env.SM_CLAUDE_VERSION);
|
|
59
|
+
if (versionCache.inflight) return versionCache.inflight;
|
|
60
|
+
if (versionCache.value !== null && now - versionCache.at < VERSION_TTL_MS) {
|
|
61
|
+
return Promise.resolve(versionCache.value);
|
|
62
|
+
}
|
|
63
|
+
const execFile = execFileImpl || require('node:child_process').execFile;
|
|
64
|
+
const bin = resolveClaudeBin();
|
|
65
|
+
versionCache.inflight = new Promise((resolve) => {
|
|
66
|
+
let settled = false;
|
|
67
|
+
const done = (value) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
versionCache = { at: Date.now(), value, inflight: null };
|
|
71
|
+
resolve(value);
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
// cwd is a neutral temp dir on purpose: the version does not depend on
|
|
75
|
+
// it, and the binary must never be handed the caller's working tree —
|
|
76
|
+
// a stub `claude` (tests point SM_CLAUDE_BIN at one) that writes
|
|
77
|
+
// markers or commits into process.cwd() once did so in the real repo.
|
|
78
|
+
execFile(bin, ['--version'], { timeout: VERSION_PROBE_TIMEOUT_MS, windowsHide: true, cwd: os.tmpdir() }, (err, stdout) => {
|
|
79
|
+
if (err) return done(null);
|
|
80
|
+
const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(String(stdout || ''));
|
|
81
|
+
done(m ? m[1] : (String(stdout || '').trim() || null));
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
done(null);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return versionCache.inflight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Test hook — forget the cached version so the next probe re-executes. */
|
|
91
|
+
function resetClaudeVersionCache() {
|
|
92
|
+
versionCache = { at: 0, value: null, inflight: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { resolveClaudeBin, probeClaudeVersion, resetClaudeVersionCache };
|
|
@@ -328,7 +328,7 @@ const RISK_HEURISTICS = [
|
|
|
328
328
|
* @param {string} headingText Case-insensitive heading to find.
|
|
329
329
|
* @returns {string}
|
|
330
330
|
*/
|
|
331
|
-
function
|
|
331
|
+
function extractSection(body, headingText) {
|
|
332
332
|
const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
333
333
|
const headingRe = new RegExp(`^#+\\s+${escapedHeading}`, 'i');
|
|
334
334
|
const lines = body.split('\n');
|
|
@@ -398,7 +398,7 @@ function flagRiskySurfaces(jobs, { prdsDir, gitTimeoutMs = 10_000 } = {}) {
|
|
|
398
398
|
try {
|
|
399
399
|
const raw = fs.readFileSync(path.join(resolvedPrdsDir, `${job.slug}.md`), 'utf8');
|
|
400
400
|
const body = splitFrontmatter(raw).body;
|
|
401
|
-
candidateText =
|
|
401
|
+
candidateText = extractSection(body, 'Implementation notes');
|
|
402
402
|
} catch { /* no candidate text — skip */ }
|
|
403
403
|
}
|
|
404
404
|
|
|
@@ -573,6 +573,7 @@ module.exports = {
|
|
|
573
573
|
reportPathFor,
|
|
574
574
|
reportExists,
|
|
575
575
|
extractAcCommand,
|
|
576
|
+
extractSection,
|
|
576
577
|
reverifyAc,
|
|
577
578
|
reverifyBatch,
|
|
578
579
|
flagRiskySurfaces,
|