claude-code-session-manager 0.8.6 → 0.10.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/README.md +95 -65
- package/dist/assets/{cssMode-DBg6nxUL.js → cssMode-DWlBzlpW.js} +1 -1
- package/dist/assets/{freemarker2-CyjUGY3f.js → freemarker2-Cgg83m-Z.js} +1 -1
- package/dist/assets/{handlebars-lhtCWqlB.js → handlebars-C4r4LOI9.js} +1 -1
- package/dist/assets/{html-egptHwbZ.js → html-DaxRI5sW.js} +1 -1
- package/dist/assets/htmlMode-Bu_8jtXo.js +1 -0
- package/dist/assets/{index-DjeqNwqn.js → index-C_tgFedf.js} +1115 -1081
- package/dist/assets/{index-DnLtSCQS.css → index-Dj3Db4OA.css} +1 -1
- package/dist/assets/{javascript-tZbiID3O.js → javascript-D5Ztx-Ej.js} +1 -1
- package/dist/assets/{jsonMode-BGtPN-L-.js → jsonMode-tfsgezVc.js} +1 -1
- package/dist/assets/{liquid-DvTeXhev.js → liquid-F2cD9OL0.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-D9xoxVlV.js → lspLanguageFeatures-Bz_Eih8F.js} +2 -2
- package/dist/assets/{mdx-BQ3Ja4wM.js → mdx-BPlD1clX.js} +1 -1
- package/dist/assets/{ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm → ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm} +0 -0
- package/dist/assets/{python-C71RWXaP.js → python-B4gUOWNI.js} +1 -1
- package/dist/assets/{razor-w__Mkyns.js → razor-B6pMxVp1.js} +1 -1
- package/dist/assets/{tsMode-DOQLQDB3.js → tsMode-C9nq6cHi.js} +1 -1
- package/dist/assets/{typescript-DEiub2Jt.js → typescript-Do5Vtwxu.js} +1 -1
- package/dist/assets/{whisperWorker-QfIS0sPF.js → whisperWorker-CcsPqZUS.js} +19 -19
- package/dist/assets/{xml-RXkLQscS.js → xml-C0mTbVRp.js} +1 -1
- package/dist/assets/{yaml-C8HIpJku.js → yaml-D3sePJfA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -10
- package/screenshots/.gitkeep +0 -0
- package/screenshots/README-screenshots.md +13 -0
- package/src/main/config.cjs +47 -9
- package/src/main/historyAggregator.cjs +10 -5
- package/src/main/index.cjs +85 -14
- package/src/main/ipcSchemas.cjs +165 -3
- package/src/main/lib/claudeBin.cjs +39 -0
- package/src/main/lib/encodeCwd.cjs +19 -0
- package/src/main/lib/fileTail.cjs +35 -0
- package/src/main/lib/insideHome.cjs +38 -0
- package/src/main/lib/prdFrontmatter.cjs +51 -0
- package/src/main/lib/sendToRenderer.cjs +21 -0
- package/src/main/memoryTool.cjs +203 -0
- package/src/main/otelSettings.cjs +2 -7
- package/src/main/pluginInstall.cjs +129 -0
- package/src/main/pty.cjs +13 -29
- package/src/main/queueOps.cjs +404 -0
- package/src/main/scheduler/prdParser.cjs +135 -0
- package/src/main/scheduler.cjs +291 -250
- package/src/main/sessionsStore.cjs +2 -6
- package/src/main/supervisor.cjs +3 -35
- package/src/main/teams.cjs +95 -0
- package/src/main/transcripts.cjs +5 -7
- package/src/main/usage.cjs +8 -0
- package/src/main/voiceHotkey.cjs +13 -9
- package/src/main/voiceSettings.cjs +2 -9
- package/src/main/voiceWizard.cjs +4 -11
- package/src/main/watchers.cjs +18 -42
- package/src/preload/api.d.ts +153 -1
- package/src/preload/index.cjs +29 -0
- package/dist/assets/htmlMode-tPDeHGOB.js +0 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin install — hidden pty wrapper around `claude plugin install <slug>`.
|
|
3
|
+
*
|
|
4
|
+
* Why pty (not exec/spawn): the CLI emits interactive progress, prompts, and
|
|
5
|
+
* colored output. A real pty gives the user faithful console output streamed
|
|
6
|
+
* into the Discover panel. Slow (~5s) but transparent.
|
|
7
|
+
*
|
|
8
|
+
* Channels:
|
|
9
|
+
* ipcMain.handle('plugins:install', { slug }) -> { ok, exitCode }
|
|
10
|
+
* webContents.send('plugins:install-progress', { slug, line })
|
|
11
|
+
*
|
|
12
|
+
* Safety:
|
|
13
|
+
* - slug must match /^[a-z0-9\-/]+$/ (lowercase, hyphen, slash for owner/name).
|
|
14
|
+
* - No shell:true — argv array passed directly to pty.spawn.
|
|
15
|
+
* - cwd pinned to os.homedir().
|
|
16
|
+
* - Only one install per slug at a time (concurrent calls reject early).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { ipcMain } = require('electron');
|
|
20
|
+
const pty = require('node-pty');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
24
|
+
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
25
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
26
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
27
|
+
|
|
28
|
+
const SLUG_RE = /^[a-z0-9\-/]+$/;
|
|
29
|
+
const MAX_LINE_BYTES = 16 * 1024;
|
|
30
|
+
const KILL_AFTER_MS = 5 * 60 * 1000; // 5 min hard ceiling per install
|
|
31
|
+
|
|
32
|
+
let mainWindow = null;
|
|
33
|
+
const inFlight = new Map(); // slug -> proc
|
|
34
|
+
|
|
35
|
+
function attachWindow(window) {
|
|
36
|
+
mainWindow = window;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function send(channel, payload) {
|
|
40
|
+
sendIfAlive(mainWindow, channel, payload);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function install({ slug }) {
|
|
44
|
+
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > 128) {
|
|
45
|
+
return Promise.resolve({ ok: false, exitCode: -1, error: 'invalid slug' });
|
|
46
|
+
}
|
|
47
|
+
if (inFlight.has(slug)) {
|
|
48
|
+
return Promise.resolve({ ok: false, exitCode: -1, error: 'install already in progress' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const home = os.homedir();
|
|
53
|
+
const extraPath = [
|
|
54
|
+
path.join(home, '.local', 'bin'),
|
|
55
|
+
path.join(home, '.npm-global', 'bin'),
|
|
56
|
+
'/usr/local/bin',
|
|
57
|
+
'/usr/bin',
|
|
58
|
+
'/bin',
|
|
59
|
+
].join(':');
|
|
60
|
+
const env = cleanChildEnv({
|
|
61
|
+
PATH: `${extraPath}:${process.env.PATH || ''}`,
|
|
62
|
+
TERM: 'xterm-256color',
|
|
63
|
+
FORCE_COLOR: '0', // strip ANSI so the renderer doesn't have to.
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const claudeBin = resolveClaudeBin();
|
|
67
|
+
let proc;
|
|
68
|
+
try {
|
|
69
|
+
proc = pty.spawn(claudeBin, ['plugin', 'install', slug], {
|
|
70
|
+
name: 'xterm-256color',
|
|
71
|
+
cols: 120,
|
|
72
|
+
rows: 30,
|
|
73
|
+
cwd: home,
|
|
74
|
+
env,
|
|
75
|
+
});
|
|
76
|
+
} catch (err) {
|
|
77
|
+
resolve({ ok: false, exitCode: -1, error: `spawn failed: ${err?.message ?? String(err)}` });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
inFlight.set(slug, proc);
|
|
82
|
+
|
|
83
|
+
let lineBuf = '';
|
|
84
|
+
const killTimer = setTimeout(() => {
|
|
85
|
+
try { proc.kill(); } catch { /* */ }
|
|
86
|
+
}, KILL_AFTER_MS);
|
|
87
|
+
|
|
88
|
+
proc.onData((data) => {
|
|
89
|
+
lineBuf += data;
|
|
90
|
+
let nl;
|
|
91
|
+
while ((nl = lineBuf.indexOf('\n')) >= 0) {
|
|
92
|
+
const line = lineBuf.slice(0, nl).replace(/\r$/, '');
|
|
93
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
94
|
+
send('plugins:install-progress', { slug, line });
|
|
95
|
+
}
|
|
96
|
+
// Guard runaway buffers without newlines.
|
|
97
|
+
if (lineBuf.length > MAX_LINE_BYTES) {
|
|
98
|
+
send('plugins:install-progress', { slug, line: lineBuf });
|
|
99
|
+
lineBuf = '';
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
proc.onExit(({ exitCode }) => {
|
|
104
|
+
clearTimeout(killTimer);
|
|
105
|
+
if (lineBuf.length > 0) {
|
|
106
|
+
send('plugins:install-progress', { slug, line: lineBuf });
|
|
107
|
+
lineBuf = '';
|
|
108
|
+
}
|
|
109
|
+
inFlight.delete(slug);
|
|
110
|
+
const code = typeof exitCode === 'number' ? exitCode : -1;
|
|
111
|
+
resolve({ ok: code === 0, exitCode: code });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function registerPluginInstallHandlers() {
|
|
117
|
+
ipcMain.handle('plugins:install', async (_e, payload) => {
|
|
118
|
+
// safeParse to preserve the existing `{ ok:false, exitCode:-1, error }`
|
|
119
|
+
// return shape on invalid input (the renderer expects this shape, not a
|
|
120
|
+
// thrown promise rejection).
|
|
121
|
+
const parsed = schemas.pluginsInstall.safeParse(payload);
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
return { ok: false, exitCode: -1, error: 'invalid slug' };
|
|
124
|
+
}
|
|
125
|
+
return install({ slug: parsed.data.slug });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { registerPluginInstallHandlers, attachWindow };
|
package/src/main/pty.cjs
CHANGED
|
@@ -5,6 +5,8 @@ const os = require('node:os');
|
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const { addAllowedRoot } = require('./config.cjs');
|
|
7
7
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
8
|
+
const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
|
|
9
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* PtyManager — owns every claude PTY process, keyed by tabId (renderer-generated UUID).
|
|
@@ -26,17 +28,9 @@ class PtyManager {
|
|
|
26
28
|
|
|
27
29
|
// Validate that cwd is inside homedir before widening the allowed-root set.
|
|
28
30
|
if (cwd) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
realCwd = fs.realpathSync(cwd);
|
|
33
|
-
} catch {
|
|
34
|
-
realCwd = path.resolve(cwd);
|
|
35
|
-
}
|
|
36
|
-
if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
|
|
37
|
-
throw new Error(`pty cwd outside home directory: ${realCwd}`);
|
|
38
|
-
}
|
|
39
|
-
addAllowedRoot(realCwd);
|
|
31
|
+
const r = assertCwdInsideHome(cwd);
|
|
32
|
+
if (!r.ok) throw new Error(`pty ${r.error}`);
|
|
33
|
+
addAllowedRoot(r.realCwd);
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
// Idempotent reattach: renderer reloads (HMR/Ctrl+R) re-run App.tsx's
|
|
@@ -62,9 +56,7 @@ class PtyManager {
|
|
|
62
56
|
if (existing.proc.exitCode != null) {
|
|
63
57
|
const exitCode = existing.proc.exitCode;
|
|
64
58
|
setImmediate(() => {
|
|
65
|
-
|
|
66
|
-
this.window.webContents.send(`pty:exit:${tabId}`, { exitCode, signal: undefined });
|
|
67
|
-
}
|
|
59
|
+
sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal: undefined });
|
|
68
60
|
});
|
|
69
61
|
}
|
|
70
62
|
return { pid: existing.proc.pid, cwd: existing.cwd, reattached: true };
|
|
@@ -108,9 +100,7 @@ class PtyManager {
|
|
|
108
100
|
console.log('[pty] spawned pid=', proc.pid, 'for tabId=', tabId);
|
|
109
101
|
|
|
110
102
|
proc.onData((data) => {
|
|
111
|
-
|
|
112
|
-
this.window.webContents.send(`pty:data:${tabId}`, data);
|
|
113
|
-
}
|
|
103
|
+
sendIfAlive(this.window, `pty:data:${tabId}`, data);
|
|
114
104
|
});
|
|
115
105
|
|
|
116
106
|
proc.onExit(({ exitCode, signal }) => {
|
|
@@ -122,9 +112,7 @@ class PtyManager {
|
|
|
122
112
|
this.sessions.delete(tabId);
|
|
123
113
|
return;
|
|
124
114
|
}
|
|
125
|
-
|
|
126
|
-
this.window.webContents.send(`pty:exit:${tabId}`, { exitCode, signal });
|
|
127
|
-
}
|
|
115
|
+
sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal });
|
|
128
116
|
this.sessions.delete(tabId);
|
|
129
117
|
});
|
|
130
118
|
|
|
@@ -137,9 +125,7 @@ class PtyManager {
|
|
|
137
125
|
if (!s) {
|
|
138
126
|
// Tab was removed or never existed — tell the renderer so it can surface
|
|
139
127
|
// "skipped" feedback rather than silently dropping the write.
|
|
140
|
-
|
|
141
|
-
this.window.webContents.send('pty:write-error', { tabId, reason: 'no-pty' });
|
|
142
|
-
}
|
|
128
|
+
sendIfAlive(this.window, 'pty:write-error', { tabId, reason: 'no-pty' });
|
|
143
129
|
return;
|
|
144
130
|
}
|
|
145
131
|
try {
|
|
@@ -149,12 +135,10 @@ class PtyManager {
|
|
|
149
135
|
// error that node-pty re-throws) when writing to an exited process.
|
|
150
136
|
// Catch here so the uncaught-exception handler never sees it, and notify
|
|
151
137
|
// the renderer to surface "skipped" feedback.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
157
|
-
}
|
|
138
|
+
sendIfAlive(this.window, 'pty:write-error', {
|
|
139
|
+
tabId,
|
|
140
|
+
reason: String(err?.message || 'write-failed'),
|
|
141
|
+
});
|
|
158
142
|
}
|
|
159
143
|
}
|
|
160
144
|
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueOps.cjs — bulk PRD operations + queue-health linter.
|
|
3
|
+
*
|
|
4
|
+
* This module is owned by Bundle D. It registers three IPC handlers:
|
|
5
|
+
*
|
|
6
|
+
* schedule:lint-queue — scan all PRDs in PRDS_DIR for anti-patterns
|
|
7
|
+
* schedule:archive-prd — move PRDS_DIR/<slug>.md → prds-archived/<ISO>/<slug>.md
|
|
8
|
+
* schedule:retag-prd — rewrite parallelGroup and/or estimateMinutes
|
|
9
|
+
* frontmatter; optionally rename slug to reflect
|
|
10
|
+
* the new NN- prefix
|
|
11
|
+
*
|
|
12
|
+
* All file mutations go through tmp + rename for atomicity. Path containment
|
|
13
|
+
* is enforced against PRDS_DIR via path.resolve() + startsWith() (mirrors
|
|
14
|
+
* scheduler.cjs's existing pattern).
|
|
15
|
+
*
|
|
16
|
+
* The linter rules come from research-04 §1 (queue-health) plus PRD 106's
|
|
17
|
+
* regex inventory. Rules:
|
|
18
|
+
* - `^until ` at the start of a logical line → fizzpop-style poll hang
|
|
19
|
+
* - `^while true` (case insensitive) → unbounded while loop
|
|
20
|
+
* - `for .* in $(seq 1 [5-9][0-9][0-9]+)` → unbounded large seq
|
|
21
|
+
* - missing frontmatter title / cwd / estimateMinutes
|
|
22
|
+
* - cwd doesn't exist on disk (fs.accessSync)
|
|
23
|
+
* - `--no-verify` or `--no-gpg-sign` → hook-skip flags
|
|
24
|
+
*
|
|
25
|
+
* Severity: 'error' for the loop patterns and missing required frontmatter;
|
|
26
|
+
* 'warn' for the rest.
|
|
27
|
+
*
|
|
28
|
+
* Time complexity: O(F × L) where F = number of PRD files, L = avg line count.
|
|
29
|
+
* Hot-loop-safe: regex tests are pre-compiled module-level constants.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const fsp = require('node:fs/promises');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const { ipcMain } = require('electron');
|
|
37
|
+
const { SCHEDULE_SLUG_RE: SLUG_RE, schemas } = require('./ipcSchemas.cjs');
|
|
38
|
+
const logs = require('./logs.cjs');
|
|
39
|
+
const config = require('./config.cjs');
|
|
40
|
+
|
|
41
|
+
const ROOT = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans');
|
|
42
|
+
const PRDS_DIR = path.join(ROOT, 'prds');
|
|
43
|
+
const PRDS_ARCHIVE_DIR = path.join(ROOT, 'prds-archived');
|
|
44
|
+
const RETAG_LOG = path.join(ROOT, 'retag-log.jsonl');
|
|
45
|
+
|
|
46
|
+
// ────────────────────────────────────────────── lint rules
|
|
47
|
+
|
|
48
|
+
// Each rule: { id, severity, test: (line) => boolean }
|
|
49
|
+
// O(1) regex test per line. Module-level so the compiled forms don't churn.
|
|
50
|
+
const LINE_RULES = [
|
|
51
|
+
{
|
|
52
|
+
id: 'unbounded-until',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
re: /^\s*until\s+/,
|
|
55
|
+
label: '"until" loop — risk of unbounded poll (see PRD 106 §1)',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'while-true',
|
|
59
|
+
severity: 'error',
|
|
60
|
+
re: /^\s*while\s+(?:true|:)/i,
|
|
61
|
+
label: '"while true" — unbounded loop',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'unbounded-seq',
|
|
65
|
+
severity: 'error',
|
|
66
|
+
// for X in $(seq 1 NNN) where NNN ≥ 500. PRD 106 §1 cites this exact pattern.
|
|
67
|
+
re: /for\s+\S+\s+in\s+\$\(seq\s+1\s+[5-9][0-9]{2,}/,
|
|
68
|
+
label: 'unbounded seq — for-loop range ≥500',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'no-verify',
|
|
72
|
+
severity: 'warn',
|
|
73
|
+
re: /--no-verify\b/,
|
|
74
|
+
label: '--no-verify — skips git hooks',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'no-gpg-sign',
|
|
78
|
+
severity: 'warn',
|
|
79
|
+
re: /--no-gpg-sign\b/,
|
|
80
|
+
label: '--no-gpg-sign — bypasses signing',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lint a single PRD asynchronously. Returns { slug, findings: [...] }.
|
|
88
|
+
* O(L) per PRD where L is line count of the file. Reads via fsp so kernel
|
|
89
|
+
* I/O can overlap when called via Promise.all from lintAll().
|
|
90
|
+
*/
|
|
91
|
+
async function lintOneAsync(filePath) {
|
|
92
|
+
const slug = path.basename(filePath, '.md');
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = await fsp.readFile(filePath, 'utf8');
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return {
|
|
98
|
+
slug,
|
|
99
|
+
findings: [{ rule: 'read-error', line: 0, snippet: e?.message ?? 'read failed', severity: 'error' }],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return lintParsed(slug, raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lintParsed(slug, raw) {
|
|
106
|
+
const findings = [];
|
|
107
|
+
const { fm, body, fmLineCount } = splitFrontmatter(raw);
|
|
108
|
+
|
|
109
|
+
// Required frontmatter keys.
|
|
110
|
+
if (!fm.title || !fm.title.trim()) {
|
|
111
|
+
findings.push({ rule: 'missing-title', line: 1, snippet: 'frontmatter "title" is required', severity: 'error' });
|
|
112
|
+
}
|
|
113
|
+
if (!fm.cwd || !fm.cwd.trim()) {
|
|
114
|
+
findings.push({ rule: 'missing-cwd', line: 1, snippet: 'frontmatter "cwd" is required', severity: 'error' });
|
|
115
|
+
} else {
|
|
116
|
+
// cwd existence — only if it looks like an absolute path. ~ -> homedir.
|
|
117
|
+
let candidate = fm.cwd;
|
|
118
|
+
if (candidate.startsWith('~/')) candidate = path.join(os.homedir(), candidate.slice(2));
|
|
119
|
+
if (path.isAbsolute(candidate)) {
|
|
120
|
+
try {
|
|
121
|
+
fs.accessSync(candidate, fs.constants.F_OK);
|
|
122
|
+
} catch {
|
|
123
|
+
findings.push({
|
|
124
|
+
rule: 'cwd-missing',
|
|
125
|
+
line: 1,
|
|
126
|
+
snippet: `cwd does not exist on disk: ${fm.cwd}`,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (fm.estimateMinutes === undefined || !String(fm.estimateMinutes).trim()) {
|
|
133
|
+
findings.push({
|
|
134
|
+
rule: 'missing-estimate',
|
|
135
|
+
line: 1,
|
|
136
|
+
snippet: 'frontmatter "estimateMinutes" is required',
|
|
137
|
+
severity: 'warn',
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
const v = Number(fm.estimateMinutes);
|
|
141
|
+
if (!Number.isInteger(v) || v <= 0) {
|
|
142
|
+
findings.push({
|
|
143
|
+
rule: 'bad-estimate',
|
|
144
|
+
line: 1,
|
|
145
|
+
snippet: `"estimateMinutes" must be a positive integer (got: ${fm.estimateMinutes})`,
|
|
146
|
+
severity: 'warn',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Body line scan. Line numbers are 1-indexed and reflect the original file
|
|
152
|
+
// (so fm lines are skipped). O(L).
|
|
153
|
+
const bodyLines = body.split('\n');
|
|
154
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
155
|
+
const line = bodyLines[i];
|
|
156
|
+
if (!line) continue;
|
|
157
|
+
for (const rule of LINE_RULES) {
|
|
158
|
+
if (rule.re.test(line)) {
|
|
159
|
+
findings.push({
|
|
160
|
+
rule: rule.id,
|
|
161
|
+
line: fmLineCount + i + 1,
|
|
162
|
+
snippet: `${rule.label} — ${line.trim().slice(0, 80)}`,
|
|
163
|
+
severity: rule.severity,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { slug, findings };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Lint all PRDs in PRDS_DIR. Returns { reports: [...], scannedAt }.
|
|
174
|
+
* O(F × L). F is bounded by the actual prds/ contents (~200 today).
|
|
175
|
+
*
|
|
176
|
+
* Reads are issued in parallel via Promise.all so kernel-side I/O can
|
|
177
|
+
* overlap. Each lintOneAsync is self-isolating (its own try/catch on read),
|
|
178
|
+
* so Promise.all never rejects under normal use.
|
|
179
|
+
*/
|
|
180
|
+
async function lintAll() {
|
|
181
|
+
let entries = [];
|
|
182
|
+
try {
|
|
183
|
+
entries = await fsp.readdir(PRDS_DIR);
|
|
184
|
+
} catch {
|
|
185
|
+
return { reports: [], scannedAt: Date.now() };
|
|
186
|
+
}
|
|
187
|
+
const paths = [];
|
|
188
|
+
for (const name of entries) {
|
|
189
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
190
|
+
paths.push(path.join(PRDS_DIR, name));
|
|
191
|
+
}
|
|
192
|
+
const reports = await Promise.all(paths.map(async (filePath) => {
|
|
193
|
+
try {
|
|
194
|
+
return await lintOneAsync(filePath);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
return {
|
|
197
|
+
slug: path.basename(filePath, '.md'),
|
|
198
|
+
findings: [{ rule: 'lint-error', line: 0, snippet: e?.message ?? 'lint failed', severity: 'error' }],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
return { reports, scannedAt: Date.now() };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ────────────────────────────────────────────── archive
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Move PRDS_DIR/<slug>.md → PRDS_ARCHIVE_DIR/<ISO>/<slug>.md.
|
|
209
|
+
* Atomic rename. Path containment checks both source and destination.
|
|
210
|
+
* Never deletes — always reversible from prds-archived/.
|
|
211
|
+
*/
|
|
212
|
+
async function archiveOne(slug, archiveDir) {
|
|
213
|
+
if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
|
|
214
|
+
const src = path.resolve(path.join(PRDS_DIR, `${slug}.md`));
|
|
215
|
+
if (!src.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'path escape (src)' };
|
|
216
|
+
const dst = path.resolve(path.join(archiveDir, `${slug}.md`));
|
|
217
|
+
if (!dst.startsWith(PRDS_ARCHIVE_DIR + path.sep)) return { ok: false, slug, error: 'path escape (dst)' };
|
|
218
|
+
try {
|
|
219
|
+
await fsp.rename(src, dst);
|
|
220
|
+
return { ok: true, slug, archivedTo: dst };
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return { ok: false, slug, error: e?.message ?? 'rename failed' };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function archiveMany(slugs) {
|
|
227
|
+
if (!Array.isArray(slugs) || slugs.length === 0) {
|
|
228
|
+
return { ok: true, archived: 0, archivedTo: null, results: [] };
|
|
229
|
+
}
|
|
230
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
231
|
+
const archiveDir = path.join(PRDS_ARCHIVE_DIR, ts);
|
|
232
|
+
try {
|
|
233
|
+
await fsp.mkdir(archiveDir, { recursive: true });
|
|
234
|
+
} catch (e) {
|
|
235
|
+
logs.writeLine({ level: 'error', scope: 'queueOps', message: 'archiveMany: mkdir failed', meta: { error: e?.message } });
|
|
236
|
+
return { ok: false, archived: 0, archivedTo: null, results: [], error: e?.message ?? 'mkdir failed' };
|
|
237
|
+
}
|
|
238
|
+
const results = [];
|
|
239
|
+
for (const slug of slugs) {
|
|
240
|
+
results.push(await archiveOne(slug, archiveDir));
|
|
241
|
+
}
|
|
242
|
+
const archived = results.filter((r) => r.ok).length;
|
|
243
|
+
return { ok: true, archived, archivedTo: archiveDir, results };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ────────────────────────────────────────────── retag
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Re-serialize the frontmatter of one PRD, optionally rewriting parallelGroup
|
|
250
|
+
* and/or estimateMinutes. If parallelGroup changes AND the slug has an NN-
|
|
251
|
+
* prefix, the file is renamed to reflect the new prefix (the old NN is
|
|
252
|
+
* stripped and replaced; if no prefix exists, one is prepended).
|
|
253
|
+
*
|
|
254
|
+
* Returns { ok, slug, newSlug, before, after, error? }.
|
|
255
|
+
*
|
|
256
|
+
* Mutates only the keys explicitly passed — preserves all other frontmatter
|
|
257
|
+
* lines verbatim (line-level replace, not a full YAML reparse). Reversible
|
|
258
|
+
* via retag-log.jsonl.
|
|
259
|
+
*/
|
|
260
|
+
async function retagOne({ slug, parallelGroup, estimateMinutes }) {
|
|
261
|
+
if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
|
|
262
|
+
const src = path.resolve(path.join(PRDS_DIR, `${slug}.md`));
|
|
263
|
+
if (!src.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'path escape' };
|
|
264
|
+
|
|
265
|
+
let raw;
|
|
266
|
+
try {
|
|
267
|
+
raw = await fsp.readFile(src, 'utf8');
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return { ok: false, slug, error: `read failed: ${e?.message}` };
|
|
270
|
+
}
|
|
271
|
+
if (!raw.startsWith('---\n')) {
|
|
272
|
+
return { ok: false, slug, error: 'no frontmatter to retag' };
|
|
273
|
+
}
|
|
274
|
+
const end = raw.indexOf('\n---', 4);
|
|
275
|
+
if (end === -1) return { ok: false, slug, error: 'unterminated frontmatter' };
|
|
276
|
+
|
|
277
|
+
const fmRaw = raw.slice(4, end);
|
|
278
|
+
const bodyTail = raw.slice(end); // includes "\n---..."
|
|
279
|
+
const fmLines = fmRaw.split('\n');
|
|
280
|
+
|
|
281
|
+
// Snapshot before-values for the retag log.
|
|
282
|
+
const before = {};
|
|
283
|
+
const newFmLines = [];
|
|
284
|
+
const seen = { parallelGroup: false, estimateMinutes: false };
|
|
285
|
+
for (const line of fmLines) {
|
|
286
|
+
const m = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/);
|
|
287
|
+
if (!m) {
|
|
288
|
+
newFmLines.push(line);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const key = m[1];
|
|
292
|
+
if (key === 'parallelGroup' && parallelGroup !== undefined) {
|
|
293
|
+
before.parallelGroup = m[2];
|
|
294
|
+
newFmLines.push(`parallelGroup: ${parallelGroup}`);
|
|
295
|
+
seen.parallelGroup = true;
|
|
296
|
+
} else if (key === 'estimateMinutes' && estimateMinutes !== undefined) {
|
|
297
|
+
before.estimateMinutes = m[2];
|
|
298
|
+
newFmLines.push(`estimateMinutes: ${estimateMinutes}`);
|
|
299
|
+
seen.estimateMinutes = true;
|
|
300
|
+
} else {
|
|
301
|
+
newFmLines.push(line);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (parallelGroup !== undefined && !seen.parallelGroup) {
|
|
305
|
+
newFmLines.push(`parallelGroup: ${parallelGroup}`);
|
|
306
|
+
}
|
|
307
|
+
if (estimateMinutes !== undefined && !seen.estimateMinutes) {
|
|
308
|
+
newFmLines.push(`estimateMinutes: ${estimateMinutes}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const newRaw = `---\n${newFmLines.join('\n')}${bodyTail}`;
|
|
312
|
+
|
|
313
|
+
// Decide whether to rename the file. NN-kebab pattern only.
|
|
314
|
+
let newSlug = slug;
|
|
315
|
+
if (parallelGroup !== undefined) {
|
|
316
|
+
const m = slug.match(/^(\d+)-(.+)$/);
|
|
317
|
+
if (m) {
|
|
318
|
+
newSlug = `${parallelGroup}-${m[2]}`;
|
|
319
|
+
} else {
|
|
320
|
+
// Slug doesn't have NN- prefix — prepend one.
|
|
321
|
+
newSlug = `${parallelGroup}-${slug}`;
|
|
322
|
+
}
|
|
323
|
+
if (!SLUG_RE.test(newSlug)) return { ok: false, slug, error: 'new slug would be invalid' };
|
|
324
|
+
}
|
|
325
|
+
const dst = path.resolve(path.join(PRDS_DIR, `${newSlug}.md`));
|
|
326
|
+
if (!dst.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'new path escape' };
|
|
327
|
+
|
|
328
|
+
// Atomic write via shared helper. If slug changed, write at the new path
|
|
329
|
+
// and unlink the old slug.
|
|
330
|
+
try {
|
|
331
|
+
await config.writeTextAtomic(dst, newRaw);
|
|
332
|
+
if (dst !== src) {
|
|
333
|
+
try { await fsp.unlink(src); } catch { /* if src is already same as dst (race), fine */ }
|
|
334
|
+
}
|
|
335
|
+
} catch (e) {
|
|
336
|
+
return { ok: false, slug, error: `write failed: ${e?.message}` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const after = {};
|
|
340
|
+
if (parallelGroup !== undefined) after.parallelGroup = String(parallelGroup);
|
|
341
|
+
if (estimateMinutes !== undefined) after.estimateMinutes = String(estimateMinutes);
|
|
342
|
+
if (newSlug !== slug) {
|
|
343
|
+
before.slug = slug;
|
|
344
|
+
after.slug = newSlug;
|
|
345
|
+
}
|
|
346
|
+
return { ok: true, slug, newSlug, before, after };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function appendRetagLog(entries) {
|
|
350
|
+
if (entries.length === 0) return;
|
|
351
|
+
try {
|
|
352
|
+
await fsp.mkdir(ROOT, { recursive: true });
|
|
353
|
+
const lines = entries.map((e) => JSON.stringify({ ts: new Date().toISOString(), ...e }) + '\n').join('');
|
|
354
|
+
await fsp.appendFile(RETAG_LOG, lines);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'retag log append failed', meta: { error: e?.message } });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function retagMany(items) {
|
|
361
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
362
|
+
return { ok: true, retagged: 0, results: [] };
|
|
363
|
+
}
|
|
364
|
+
const results = [];
|
|
365
|
+
for (const item of items) {
|
|
366
|
+
results.push(await retagOne(item));
|
|
367
|
+
}
|
|
368
|
+
await appendRetagLog(results.filter((r) => r.ok));
|
|
369
|
+
const retagged = results.filter((r) => r.ok).length;
|
|
370
|
+
return { ok: true, retagged, results };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ────────────────────────────────────────────── IPC registration
|
|
374
|
+
|
|
375
|
+
function registerQueueOpsHandlers() {
|
|
376
|
+
ipcMain.handle('schedule:lint-queue', async () => {
|
|
377
|
+
return lintAll();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
ipcMain.handle('schedule:archive-prd', async (_e, payload) => {
|
|
381
|
+
let parsed;
|
|
382
|
+
try { parsed = schemas.scheduleArchivePrd.parse(payload); }
|
|
383
|
+
catch (e) { return { ok: false, error: e?.message ?? 'invalid payload' }; }
|
|
384
|
+
return archiveMany(parsed.slugs);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
ipcMain.handle('schedule:retag-prd', async (_e, payload) => {
|
|
388
|
+
let parsed;
|
|
389
|
+
try { parsed = schemas.scheduleRetagPrd.parse(payload); }
|
|
390
|
+
catch (e) { return { ok: false, error: e?.message ?? 'invalid payload' }; }
|
|
391
|
+
return retagMany(parsed.items);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module.exports = {
|
|
396
|
+
registerQueueOpsHandlers,
|
|
397
|
+
// Exposed for tests / future direct calls.
|
|
398
|
+
lintAll,
|
|
399
|
+
lintOneAsync,
|
|
400
|
+
archiveMany,
|
|
401
|
+
retagMany,
|
|
402
|
+
PRDS_DIR,
|
|
403
|
+
PRDS_ARCHIVE_DIR,
|
|
404
|
+
};
|