claude-code-session-manager 0.116.0 → 0.117.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-lXvlmehy.js → AgentLibrary-BLRc_YvR.js} +2 -2
- package/dist/assets/{Button-C6ZxRSs0.js → Button-DHEnLfDi.js} +1 -1
- package/dist/assets/{DataModel-CRNjAcGi.js → DataModel-C2ytUxJm.js} +1 -1
- package/dist/assets/{History-CBIYR5DT.js → History-eoVSG_DX.js} +2 -2
- package/dist/assets/{Hooks-DClb8etC.js → Hooks-rS9UvMcI.js} +3 -3
- package/dist/assets/{Library-CB61goZg.js → Library-CdUqaZIj.js} +7 -7
- package/dist/assets/{ListDetail-6mP7on1x.js → ListDetail-DhtJPQvm.js} +1 -1
- package/dist/assets/{McpServers-CgEhkk0x.js → McpServers-DoOcEGFd.js} +2 -2
- package/dist/assets/Memory-BbYwtTBZ.js +25 -0
- package/dist/assets/{Panel-DRVTJ70d.js → Panel-M4ouFOKI.js} +1 -1
- package/dist/assets/{Permissions-crk6UxUN.js → Permissions-DB0DjkgF.js} +3 -3
- package/dist/assets/{Plugins-DnqP6tpm.js → Plugins-DnBwH9PN.js} +2 -2
- package/dist/assets/ProvenanceBadge-1MQmqxxO.js +1 -0
- package/dist/assets/RemoteMachines-DnT08vg8.js +3 -0
- package/dist/assets/{SaveBar-DO1z0hF3.js → SaveBar-DGBVV6ro.js} +1 -1
- package/dist/assets/{Scheduler-BqFJU2sP.js → Scheduler-CGDLYpdc.js} +8 -8
- package/dist/assets/{ScopeSwitcher-BVlm3X8m.js → ScopeSwitcher-jgpoKdvH.js} +1 -1
- package/dist/assets/{Settings-CtVzhDIC.js → Settings-Ch0zc2cZ.js} +4 -4
- package/dist/assets/{SkillReferenceGraph-DeLStEUI.js → SkillReferenceGraph-Cd04i1nZ.js} +1 -1
- package/dist/assets/{Skills-D3tIYqb5.js → Skills-DLJLLulV.js} +1 -1
- package/dist/assets/SystemPrompt-E4VOiMJN.js +1 -0
- package/dist/assets/TagLibrary-2sCAw54h.js +1 -0
- package/dist/assets/{TiptapBody-CmYsaMda.js → TiptapBody-CzqJev6v.js} +1 -1
- package/dist/assets/{Toggle-CveuKhe8.js → Toggle-D5znlR_5.js} +1 -1
- package/dist/assets/{ViewTabs-18pC8oXm.js → ViewTabs-BENAWvOs.js} +1 -1
- package/dist/assets/{historyTotals-Bg6D8W2m.js → historyTotals-DsE82z-I.js} +1 -1
- package/dist/assets/{index-iwwBBg_v.js → index-B_3CV5Po.js} +501 -500
- package/dist/assets/{index-CZsDDyLy.css → index-DAMBm3UN.css} +1 -1
- package/dist/assets/{settingsSchema-DLcTX-tX.js → settingsSchema-CmuwD4OX.js} +1 -1
- package/dist/assets/{skillFrontmatter-Css435Xo.js → skillFrontmatter-CdlHJV9x.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-BYEGo9_9.js → useAgentsDirWatch-RVq9wMFY.js} +1 -1
- package/dist/assets/{useRemotePeers-DrjfyULz.js → useRemotePeers-Cpt47RWb.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
- package/scripts/scheduler-mcp-server.cjs +16 -1
- package/src/main/build-info.json +4 -4
- package/src/main/lib/feedback-remote/macroAdminRoutes.cjs +2 -1
- package/src/main/lib/git/gitWorktree.cjs +7 -0
- package/src/main/lib/git/worktreeDeps.cjs +77 -0
- package/src/main/lib/mcpToolCatalog.cjs +6 -2
- package/src/main/lib/memoryMdNetwork.cjs +121 -0
- package/src/main/lib/memoryRefResolve.cjs +64 -0
- package/src/main/lib/memoryStale.cjs +27 -11
- package/src/main/lib/persona-macro/macroLibrary.cjs +49 -2
- package/src/main/lib/scheduler/baselineGate.cjs +128 -0
- package/src/main/lib/scheduler/definitionOfDone.cjs +9 -3
- package/src/main/lib/scheduler/needsReviewLedger.cjs +1 -1
- package/src/main/lib/scheduler/reviewNotice.cjs +53 -0
- package/src/main/memoryAssist.cjs +71 -31
- package/src/main/memoryTool.cjs +18 -6
- package/src/main/promptSessionEvents.cjs +1 -1
- package/src/main/runVerify.cjs +39 -3
- package/src/main/scheduler/reviewLadder.cjs +25 -6
- package/src/main/scheduler.cjs +83 -1
- package/src/main/templates/PRD_AUTHORING.md +2 -0
- package/src/preload/api.d.ts +26 -3
- package/dist/assets/Memory-ISEb5yPN.js +0 -18
- package/dist/assets/ProvenanceBadge-DK2dwSU7.js +0 -1
- package/dist/assets/RemoteMachines-7d2sUA_3.js +0 -3
- package/dist/assets/SystemPrompt-vpwQTYnh.js +0 -1
- package/dist/assets/TagLibrary-D0PZAUh5.js +0 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktreeDeps.cjs — symlink the main checkout's gitignored top-level
|
|
3
|
+
* dependency dirs (node_modules, .venv, venv) into a fresh worktree.
|
|
4
|
+
*
|
|
5
|
+
* `git worktree add` checks out tracked files only, so a job worktree has no
|
|
6
|
+
* dependencies and executors park needs_review. A gitignore pattern like
|
|
7
|
+
* `node_modules/` (trailing slash) matches directories only and a symlink is
|
|
8
|
+
* not a directory, so each linked name is also written to the repo's shared
|
|
9
|
+
* `info/exclude` as `/<name>` — otherwise the link shows as untracked and the
|
|
10
|
+
* job parks as `uncommitted_changes`.
|
|
11
|
+
*
|
|
12
|
+
* Never throws; failures land in `skipped`.
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { execGit } = require('./gitExec.cjs');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DEP_DIRS = ['node_modules', '.venv', 'venv'];
|
|
21
|
+
|
|
22
|
+
function isDir(p) {
|
|
23
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exists(p) {
|
|
27
|
+
try { fs.lstatSync(p); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function isIgnored(cwd, name) {
|
|
31
|
+
try {
|
|
32
|
+
await execGit(cwd, ['check-ignore', '-q', name], { timeout: 10_000 });
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function addExcludes(mainCwd, names) {
|
|
38
|
+
const out = (await execGit(mainCwd, ['rev-parse', '--git-common-dir'], { timeout: 10_000 })).trim();
|
|
39
|
+
const commonDir = path.resolve(mainCwd, out);
|
|
40
|
+
const file = path.join(commonDir, 'info', 'exclude');
|
|
41
|
+
let existing = '';
|
|
42
|
+
try { existing = fs.readFileSync(file, 'utf8'); } catch { /* absent */ }
|
|
43
|
+
const have = new Set(existing.split('\n').map((l) => l.trim()));
|
|
44
|
+
const missing = names.map((n) => `/${n}`).filter((l) => !have.has(l));
|
|
45
|
+
if (!missing.length) return;
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
const sep = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
48
|
+
fs.appendFileSync(file, `${sep}${missing.join('\n')}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{ mainCwd: string, worktreeCwd: string, names?: string[] }} opts
|
|
53
|
+
* @returns {Promise<{ linked: string[], skipped: string[] }>}
|
|
54
|
+
*/
|
|
55
|
+
async function linkDependencyDirs({ mainCwd, worktreeCwd, names = DEFAULT_DEP_DIRS }) {
|
|
56
|
+
const linked = [];
|
|
57
|
+
const skipped = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
try {
|
|
60
|
+
const src = path.join(mainCwd, name);
|
|
61
|
+
const dest = path.join(worktreeCwd, name);
|
|
62
|
+
if (!isDir(src) || exists(dest) || !(await isIgnored(mainCwd, name))) {
|
|
63
|
+
skipped.push(name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Exclude first: a link without its exclude line would dirty status.
|
|
67
|
+
await addExcludes(mainCwd, [name]);
|
|
68
|
+
fs.symlinkSync(src, dest, 'dir');
|
|
69
|
+
linked.push(name);
|
|
70
|
+
} catch {
|
|
71
|
+
skipped.push(name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { linked, skipped };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { linkDependencyDirs, DEFAULT_DEP_DIRS };
|
|
@@ -365,10 +365,14 @@ const MCP_TOOL_CATALOG = [
|
|
|
365
365
|
label: 'Publish git + npm',
|
|
366
366
|
agentName: 'builder',
|
|
367
367
|
tag: 'feature',
|
|
368
|
-
prompt: 'Run the full publish flow: commit, bump version, gate, npm publish, push.',
|
|
368
|
+
prompt: 'Run the full publish flow for the {{stream}} stream: commit, bump version, gate, npm publish, push.',
|
|
369
|
+
inputs: [{ name: 'stream', label: 'Stream', default: 'education-veggies' }],
|
|
369
370
|
},
|
|
370
371
|
notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or "
|
|
371
|
-
+ 'process.cwd()) when omitted. label, agentName, tag and prompt are all required strings.'
|
|
372
|
+
+ 'process.cwd()) when omitted. label, agentName, tag and prompt are all required strings. '
|
|
373
|
+
+ '`inputs` is an optional array of { name, label, default, required, options } fields the human fills in '
|
|
374
|
+
+ 'when pressing the macro; reference each in the prompt as {{name}}. Omitting `inputs` on update keeps '
|
|
375
|
+
+ 'the existing inputs; `[]` clears them.',
|
|
372
376
|
},
|
|
373
377
|
{
|
|
374
378
|
name: 'session_manager_help',
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gathers the project's markdown network (root CLAUDE.md, the docs it links to,
|
|
5
|
+
* every tracked scoped CLAUDE.md) and reports which docs already mention the
|
|
6
|
+
* backticked terms a memory uses. Top-level in lib/ (sibling of memoryStale):
|
|
7
|
+
* no domain subfolder fits. Pure apart from the injected `readFile`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const ROOT_MAX_BYTES = 16 * 1024;
|
|
14
|
+
const MAX_DOCS = 60;
|
|
15
|
+
const MAX_SEARCH_BYTES = 200 * 1024;
|
|
16
|
+
const MAX_TERMS = 25;
|
|
17
|
+
const HITS_PER_DOC = 3;
|
|
18
|
+
const HITS_TOTAL = 8;
|
|
19
|
+
const MAX_LINE_CHARS = 200;
|
|
20
|
+
|
|
21
|
+
const MD_LINK_RE = /\]\(([^)\s]+\.md)(#[^)]*)?\)/g;
|
|
22
|
+
const TERM_RE = /`([^`\n]{4,80})`/g;
|
|
23
|
+
const ROOT_DOC = 'CLAUDE.md';
|
|
24
|
+
|
|
25
|
+
function defaultReadFile(p) {
|
|
26
|
+
return fs.readFileSync(p, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeRead(readFile, abs) {
|
|
30
|
+
try {
|
|
31
|
+
const out = readFile(abs);
|
|
32
|
+
return typeof out === 'string' ? out : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractLinkedDocs(rootText) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let m;
|
|
41
|
+
MD_LINK_RE.lastIndex = 0;
|
|
42
|
+
while ((m = MD_LINK_RE.exec(rootText)) !== null) {
|
|
43
|
+
const target = m[1];
|
|
44
|
+
if (/^(?:https?|file):\/\//i.test(target)) continue;
|
|
45
|
+
const norm = path.posix.normalize(target);
|
|
46
|
+
if (norm.startsWith('..') || norm.startsWith('/')) continue;
|
|
47
|
+
out.push(norm);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractTerms(body) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
let m;
|
|
56
|
+
TERM_RE.lastIndex = 0;
|
|
57
|
+
while ((m = TERM_RE.exec(body || '')) !== null) {
|
|
58
|
+
if (out.length >= MAX_TERMS) break;
|
|
59
|
+
const term = m[1].trim();
|
|
60
|
+
if (term.length < 4 || seen.has(term)) continue;
|
|
61
|
+
seen.add(term);
|
|
62
|
+
out.push(term);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function titleOf(text, fallback) {
|
|
68
|
+
const m = /^# (.+?)\s*$/m.exec(text);
|
|
69
|
+
return m ? m[1] : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {{ cwd: string, memoryBody: string, trackedFiles: string[], readFile?: (p: string) => string }} args
|
|
74
|
+
*/
|
|
75
|
+
function collectMdNetwork({ cwd, memoryBody, trackedFiles, readFile = defaultReadFile }) {
|
|
76
|
+
const tracked = new Set((trackedFiles || []).map((f) => path.posix.normalize(f)));
|
|
77
|
+
const abs = (rel) => path.join(cwd, rel);
|
|
78
|
+
|
|
79
|
+
const rootFull = safeRead(readFile, abs(ROOT_DOC)) ?? '';
|
|
80
|
+
const rootClaudeMd = Buffer.byteLength(rootFull) > ROOT_MAX_BYTES
|
|
81
|
+
? Buffer.from(rootFull).subarray(0, ROOT_MAX_BYTES).toString('utf8')
|
|
82
|
+
: rootFull;
|
|
83
|
+
|
|
84
|
+
const paths = [];
|
|
85
|
+
const add = (p) => {
|
|
86
|
+
if (p !== ROOT_DOC && !paths.includes(p)) paths.push(p);
|
|
87
|
+
};
|
|
88
|
+
for (const p of extractLinkedDocs(rootFull)) if (tracked.has(p)) add(p);
|
|
89
|
+
for (const p of tracked) if (path.posix.basename(p) === ROOT_DOC) add(p);
|
|
90
|
+
|
|
91
|
+
const searchable = [{ path: ROOT_DOC, text: rootFull }];
|
|
92
|
+
const docs = [];
|
|
93
|
+
for (const rel of paths) {
|
|
94
|
+
if (docs.length >= MAX_DOCS) break;
|
|
95
|
+
const text = safeRead(readFile, abs(rel));
|
|
96
|
+
if (text === null) continue;
|
|
97
|
+
const bytes = Buffer.byteLength(text);
|
|
98
|
+
docs.push({ path: rel, title: titleOf(text, rel), bytes });
|
|
99
|
+
if (bytes <= MAX_SEARCH_BYTES) searchable.push({ path: rel, text });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const coverage = extractTerms(memoryBody).map((term) => {
|
|
103
|
+
const hits = [];
|
|
104
|
+
for (const doc of searchable) {
|
|
105
|
+
if (hits.length >= HITS_TOTAL) break;
|
|
106
|
+
let perDoc = 0;
|
|
107
|
+
const lines = doc.text.split(/\r?\n/);
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
if (perDoc >= HITS_PER_DOC || hits.length >= HITS_TOTAL) break;
|
|
110
|
+
if (!lines[i].includes(term)) continue;
|
|
111
|
+
hits.push({ path: doc.path, line: i + 1, text: lines[i].trim().slice(0, MAX_LINE_CHARS) });
|
|
112
|
+
perDoc++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { term, hits };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return { rootClaudeMd, docs, coverage };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { collectMdNetwork };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one predicate deciding whether a path a memory mentions still resolves.
|
|
5
|
+
* Top-level in lib/ beside memoryStale.cjs (its sibling consumer); no domain
|
|
6
|
+
* subfolder fits. IO is injected (`exists`, `trackedFiles`, `home`) so it is
|
|
7
|
+
* unit-testable with no real fs; only `listTrackedFiles` shells out to git.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { execFile } = require('node:child_process');
|
|
14
|
+
|
|
15
|
+
function isBadRel(rel) {
|
|
16
|
+
if (typeof rel !== 'string' || rel === '') return true;
|
|
17
|
+
if (rel.startsWith('/') || rel.startsWith('~')) return true;
|
|
18
|
+
return rel.split(/[\\/]/).includes('..');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function underHome(abs, home) {
|
|
22
|
+
const r = path.relative(home, abs);
|
|
23
|
+
return r === '' || (!r.startsWith('..') && !path.isAbsolute(r));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRefResolver({ cwd, trackedFiles = [], exists = fs.existsSync, home = os.homedir() }) {
|
|
27
|
+
const exact = new Set(trackedFiles);
|
|
28
|
+
const byBase = new Map();
|
|
29
|
+
for (const p of trackedFiles) {
|
|
30
|
+
const base = path.posix.basename(p);
|
|
31
|
+
const list = byBase.get(base);
|
|
32
|
+
if (list) list.push(p); else byBase.set(base, [p]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function expandRoot(root) {
|
|
36
|
+
if (typeof root !== 'string' || root === '') return null;
|
|
37
|
+
if (root === '~') return home;
|
|
38
|
+
if (root.startsWith('~/')) return path.join(home, root.slice(2));
|
|
39
|
+
return path.isAbsolute(root) ? path.normalize(root) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return function resolve(rel, roots = []) {
|
|
43
|
+
if (isBadRel(rel)) return false;
|
|
44
|
+
if (exists(path.join(cwd, rel))) return true;
|
|
45
|
+
if (exact.has(rel)) return true;
|
|
46
|
+
const candidates = byBase.get(path.posix.basename(rel));
|
|
47
|
+
if (candidates && candidates.some((p) => p === rel || p.endsWith('/' + rel))) return true;
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
const abs = expandRoot(root);
|
|
50
|
+
if (abs && underHome(abs, home) && exists(path.join(abs, rel))) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function listTrackedFiles(cwd) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
execFile('git', ['-C', cwd, 'ls-files'], { timeout: 10_000, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => {
|
|
59
|
+
resolve(err ? [] : String(stdout).split('\n').filter(Boolean));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { makeRefResolver, listTrackedFiles };
|
|
@@ -14,11 +14,9 @@ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
|
14
14
|
// Backtick-quoted repo-path-shaped tokens, optionally suffixed with a :line.
|
|
15
15
|
const PATH_TOKEN_RE = /`([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,5}(?::\d+)?)`/g;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'txt', 'toml', 'lock', 'sql', 'php', 'swift', 'kt', 'vue',
|
|
21
|
-
]);
|
|
17
|
+
// Project roots a memory names: `~/x/y` or `/home/<user>/x/y`, backticked or not.
|
|
18
|
+
const ROOT_TOKEN_RE = /(?:~|\/home\/[^/\s`]+)\/[A-Za-z0-9_.\-/]+/g;
|
|
19
|
+
const MAX_ROOTS = 5;
|
|
22
20
|
|
|
23
21
|
function stripName(name) {
|
|
24
22
|
return name.replace(/\.md$/, '');
|
|
@@ -53,9 +51,8 @@ function extractCandidates(body) {
|
|
|
53
51
|
if (out.length >= MAX_DEAD_REF_CANDIDATES) break;
|
|
54
52
|
let token = m[1].replace(/:\d+$/, '');
|
|
55
53
|
if (token.startsWith('/') || token.startsWith('~')) continue; // not repo-relative
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!hasSlash && !KNOWN_SOURCE_EXTENSIONS.has(ext)) continue;
|
|
54
|
+
// A bare filename can never resolve from the repo root — only slashed paths are checkable.
|
|
55
|
+
if (!token.includes('/')) continue;
|
|
59
56
|
if (seen.has(token)) continue;
|
|
60
57
|
seen.add(token);
|
|
61
58
|
out.push(token);
|
|
@@ -63,12 +60,30 @@ function extractCandidates(body) {
|
|
|
63
60
|
return out;
|
|
64
61
|
}
|
|
65
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Extract up to MAX_ROOTS distinct directory tokens (`~/...`, `/home/<user>/...`)
|
|
65
|
+
* named anywhere in the body, so a sibling-repo path can resolve against them.
|
|
66
|
+
* Pure, no fs.
|
|
67
|
+
*/
|
|
68
|
+
function extractRoots(body) {
|
|
69
|
+
const out = [];
|
|
70
|
+
let m;
|
|
71
|
+
ROOT_TOKEN_RE.lastIndex = 0;
|
|
72
|
+
while ((m = ROOT_TOKEN_RE.exec(body)) !== null) {
|
|
73
|
+
const root = m[0].replace(/[/.,)]+$/, '');
|
|
74
|
+
if (!root.includes('/') || out.includes(root)) continue;
|
|
75
|
+
out.push(root);
|
|
76
|
+
if (out.length >= MAX_ROOTS) break;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/**
|
|
67
82
|
* scoreMemories({ entries, now, existsPath })
|
|
68
83
|
*
|
|
69
84
|
* entries: Array<{ name, mtimeMs, body }>
|
|
70
85
|
* now: ms epoch
|
|
71
|
-
* existsPath: (relPath: string) => boolean
|
|
86
|
+
* existsPath: (relPath: string, roots?: string[]) => boolean
|
|
72
87
|
*
|
|
73
88
|
* Complexity: O(total body bytes) — a single pass per entry to extract
|
|
74
89
|
* wikilink targets and dead-ref candidates; the inbound-link fold is
|
|
@@ -105,7 +120,8 @@ function scoreMemories({ entries, now, existsPath }) {
|
|
|
105
120
|
const inboundLinks = inboundCounts.get(slug) || 0;
|
|
106
121
|
|
|
107
122
|
const candidates = extractCandidates(e.body);
|
|
108
|
-
const
|
|
123
|
+
const roots = extractRoots(e.body);
|
|
124
|
+
const deadRefs = candidates.filter((c) => !existsPath(c, roots));
|
|
109
125
|
|
|
110
126
|
const reasons = [];
|
|
111
127
|
if (deadRefs.length > 0) {
|
|
@@ -136,4 +152,4 @@ function scoreMemories({ entries, now, existsPath }) {
|
|
|
136
152
|
});
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
module.exports = { scoreMemories };
|
|
155
|
+
module.exports = { scoreMemories, extractRoots };
|
|
@@ -60,6 +60,39 @@ const MacroProjectEntrySchema = z.union([z.literal(ALL_PROJECTS), AbsolutePathSc
|
|
|
60
60
|
const MacroProjectsSchema = z.array(MacroProjectEntrySchema).max(200);
|
|
61
61
|
const MacroSurfaceSchema = z.enum(['sessions', 'project-home']);
|
|
62
62
|
|
|
63
|
+
const MACRO_INPUT_MAX_LEN = 500;
|
|
64
|
+
const MACRO_PLACEHOLDER_RE = /\{\{\s*([a-z][a-z0-9_]{0,31})\s*\}\}/g;
|
|
65
|
+
|
|
66
|
+
const MacroInputSchema = z.object({
|
|
67
|
+
name: z.string().regex(/^[a-z][a-z0-9_]{0,31}$/, 'invalid input name'),
|
|
68
|
+
label: z.string().trim().min(1).max(60),
|
|
69
|
+
default: z.string().max(MACRO_INPUT_MAX_LEN).optional(),
|
|
70
|
+
required: z.boolean().optional(),
|
|
71
|
+
options: z.array(z.string().min(1).max(MACRO_INPUT_MAX_LEN)).min(1).max(20).optional(),
|
|
72
|
+
});
|
|
73
|
+
const MacroInputsSchema = z.array(MacroInputSchema).max(10);
|
|
74
|
+
|
|
75
|
+
/** Cross-field checks; a no-op when `inputs` is absent/empty so legacy literal `{{x}}` prompts still load. */
|
|
76
|
+
function refineInputs(value, ctx) {
|
|
77
|
+
if (!value.inputs || value.inputs.length === 0) return;
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
value.inputs.forEach((input, i) => {
|
|
80
|
+
if (seen.has(input.name)) {
|
|
81
|
+
ctx.addIssue({ code: 'custom', path: ['inputs', i, 'name'], message: `duplicate input name: ${input.name}` });
|
|
82
|
+
}
|
|
83
|
+
seen.add(input.name);
|
|
84
|
+
if (input.default !== undefined && input.options && !input.options.includes(input.default)) {
|
|
85
|
+
ctx.addIssue({ code: 'custom', path: ['inputs', i, 'default'], message: `default for "${input.name}" is not one of its options` });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const re = new RegExp(MACRO_PLACEHOLDER_RE.source, 'g');
|
|
89
|
+
for (const m of value.prompt.matchAll(re)) {
|
|
90
|
+
if (!seen.has(m[1])) {
|
|
91
|
+
ctx.addIssue({ code: 'custom', path: ['prompt'], message: `prompt placeholder {{${m[1]}}} is not declared in inputs` });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
const MacroSchema = z.object({
|
|
64
97
|
id: MacroIdSchema,
|
|
65
98
|
label: MacroLabelSchema,
|
|
@@ -72,7 +105,8 @@ const MacroSchema = z.object({
|
|
|
72
105
|
builtinVersion: z.number().int().optional(),
|
|
73
106
|
createdAt: z.string().min(1).max(64),
|
|
74
107
|
updatedAt: z.string().min(1).max(64),
|
|
75
|
-
|
|
108
|
+
inputs: MacroInputsSchema.optional(),
|
|
109
|
+
}).superRefine(refineInputs);
|
|
76
110
|
|
|
77
111
|
const MacroSaveSchema = z.object({
|
|
78
112
|
id: MacroIdSchema.optional(),
|
|
@@ -85,7 +119,9 @@ const MacroSaveSchema = z.object({
|
|
|
85
119
|
// wipe its visibility back to no projects. `undefined` on create means [].
|
|
86
120
|
projects: MacroProjectsSchema.optional(),
|
|
87
121
|
surface: MacroSurfaceSchema.optional(),
|
|
88
|
-
|
|
122
|
+
// Omitted keeps the stored list on update; `[]` clears it.
|
|
123
|
+
inputs: MacroInputsSchema.optional(),
|
|
124
|
+
}).superRefine(refineInputs);
|
|
89
125
|
|
|
90
126
|
const MacroDeleteSchema = z.object({ id: MacroIdSchema });
|
|
91
127
|
|
|
@@ -399,6 +435,13 @@ async function saveMacro(input, opts = {}) {
|
|
|
399
435
|
surface: parsed.surface ?? macros[idx].surface,
|
|
400
436
|
updatedAt: now,
|
|
401
437
|
};
|
|
438
|
+
if (parsed.inputs === undefined) {
|
|
439
|
+
// keep whatever the spread carried over
|
|
440
|
+
} else if (parsed.inputs.length === 0) {
|
|
441
|
+
delete saved.inputs;
|
|
442
|
+
} else {
|
|
443
|
+
saved.inputs = parsed.inputs;
|
|
444
|
+
}
|
|
402
445
|
macros[idx] = saved;
|
|
403
446
|
} else {
|
|
404
447
|
const projects = parsed.projects !== undefined ? normalizeProjects(parsed.projects) : [];
|
|
@@ -413,6 +456,7 @@ async function saveMacro(input, opts = {}) {
|
|
|
413
456
|
createdAt: now,
|
|
414
457
|
updatedAt: now,
|
|
415
458
|
};
|
|
459
|
+
if (parsed.inputs && parsed.inputs.length > 0) saved.inputs = parsed.inputs;
|
|
416
460
|
macros.push(saved);
|
|
417
461
|
}
|
|
418
462
|
await writeStore(filePath, macros);
|
|
@@ -467,6 +511,9 @@ async function setMacroProject(input, opts = {}) {
|
|
|
467
511
|
|
|
468
512
|
module.exports = {
|
|
469
513
|
ALL_PROJECTS,
|
|
514
|
+
MACRO_INPUT_MAX_LEN,
|
|
515
|
+
MACRO_PLACEHOLDER_RE,
|
|
516
|
+
MacroInputSchema,
|
|
470
517
|
MacroSchema,
|
|
471
518
|
MacroSaveSchema,
|
|
472
519
|
MacroDeleteSchema,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* baselineGate.cjs — pure decision rule for "baseline gate authority": whether
|
|
5
|
+
* a needs_review park whose gate is red may complete on its own because every
|
|
6
|
+
* failure at HEAD already existed at the job's base commit.
|
|
7
|
+
*
|
|
8
|
+
* The park class: an executor committed correct work, but the PRD gate is red
|
|
9
|
+
* for reasons the job did not cause ("N pre-existing failures, identical on
|
|
10
|
+
* base"). The proof is a subset test on failing-test ids — HEAD's failing ids
|
|
11
|
+
* in the failing gate step must all appear in the base run's failing ids for
|
|
12
|
+
* that SAME step. A job that adds even one new failing test is refused.
|
|
13
|
+
*
|
|
14
|
+
* Pure: no fs/git/process I/O. scheduler.cjs runs the gate at HEAD and at the
|
|
15
|
+
* base commit and passes the two runGateSequence outcomes (each result carries
|
|
16
|
+
* `outputTail`) plus the git proofs in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
21
|
+
|
|
22
|
+
/** Strip a trailing vitest/jest duration like " 12ms" / " (3 ms)". */
|
|
23
|
+
function stripDuration(s) {
|
|
24
|
+
return s.replace(/\s+\(?\d+(?:\.\d+)?\s*m?s\)?\s*$/, '').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* parseFailingTests(output) → sorted unique failing-test ids, or null when the
|
|
29
|
+
* output shows failure but no id is parseable.
|
|
30
|
+
*
|
|
31
|
+
* Formats: pytest `FAILED path::name`; vitest `FAIL path > ... > name` and
|
|
32
|
+
* `× name` under a FAIL file; jest `● Suite › name`; tsc
|
|
33
|
+
* `file(line,col): error TSnnnn` → `file:TSnnnn`.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} output
|
|
36
|
+
* @returns {string[]|null}
|
|
37
|
+
*/
|
|
38
|
+
function parseFailingTests(output) {
|
|
39
|
+
const text = typeof output === 'string' ? output.replace(ANSI_RE, '') : '';
|
|
40
|
+
const ids = new Set();
|
|
41
|
+
let currentFile = null;
|
|
42
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trimEnd();
|
|
44
|
+
let m;
|
|
45
|
+
if ((m = /^FAILED\s+(\S+?)(?:\s+-\s.*)?$/.exec(line))) {
|
|
46
|
+
ids.add(m[1]);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if ((m = /^\s*FAIL\s+(.+)$/.exec(line))) {
|
|
50
|
+
const body = stripDuration(m[1]);
|
|
51
|
+
if (body.includes(' > ')) {
|
|
52
|
+
ids.add(body);
|
|
53
|
+
currentFile = body.split(' > ')[0].trim();
|
|
54
|
+
} else {
|
|
55
|
+
currentFile = body;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (currentFile && (m = /^\s*[×✗✕]\s+(.+)$/.exec(line))) {
|
|
60
|
+
ids.add(`${currentFile} > ${stripDuration(m[1])}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if ((m = /^\s*●\s+(.+?\s›\s.+)$/.exec(line))) {
|
|
64
|
+
ids.add(m[1].trim());
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if ((m = /^(.+?)\(\d+,\d+\):\s*error\s+(TS\d+)\b/.exec(line))) {
|
|
68
|
+
ids.add(`${m[1].trim()}:${m[2]}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if ((m = /^(.+?):\d+:\d+\s+-\s*error\s+(TS\d+)\b/.exec(line))) {
|
|
72
|
+
ids.add(`${m[1].trim()}:${m[2]}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return ids.size ? [...ids].sort() : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The failing step of a red outcome: runGateSequence stops at the first failure. */
|
|
79
|
+
function failingStep(outcome) {
|
|
80
|
+
const results = Array.isArray(outcome?.results) ? outcome.results : [];
|
|
81
|
+
if (!results.length) return null;
|
|
82
|
+
const index = results.length - 1;
|
|
83
|
+
return { index, output: results[index]?.outputTail };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* decideBaselineAuthority({ job, headOutcome, baseOutcome, evidenceOk, ancestorOk, cleanOk, headStable, env })
|
|
88
|
+
* → { complete, reason, preexisting }
|
|
89
|
+
*
|
|
90
|
+
* Checks run in a fixed order; the first failed check is the reason.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} params
|
|
93
|
+
* @param {object} params.job - needs_review row: reads `status`, `verifierVerdict`, `landedCommit`.
|
|
94
|
+
* @param {{status: string, results: object[]}} params.headOutcome - runGateSequence at HEAD.
|
|
95
|
+
* @param {{status: string, results: object[]}} params.baseOutcome - runGateSequence at the job's base commit.
|
|
96
|
+
* @param {boolean} params.evidenceOk - landedCommit is git-verified evidence from THIS dispatch.
|
|
97
|
+
* @param {boolean} params.ancestorOk - landedCommit is an ancestor of (or equal to) the pre-gate HEAD.
|
|
98
|
+
* @param {boolean} params.cleanOk - tree clean before and after the gate.
|
|
99
|
+
* @param {boolean} params.headStable - HEAD unmoved across the gate.
|
|
100
|
+
* @param {object} [params.env] - defaults to process.env.
|
|
101
|
+
* @returns {{complete: boolean, reason: string, preexisting: string[]}}
|
|
102
|
+
*/
|
|
103
|
+
function decideBaselineAuthority({ job, headOutcome, baseOutcome, evidenceOk, ancestorOk, cleanOk, headStable, env = process.env } = {}) {
|
|
104
|
+
const no = (reason) => ({ complete: false, reason, preexisting: [] });
|
|
105
|
+
if (env?.SM_BASELINE_GATE_AUTHORITY_DISABLE === '1') return no('kill-switch');
|
|
106
|
+
if (!job || job.status !== 'needs_review' || job.verifierVerdict !== 'sentinel_fail') {
|
|
107
|
+
return no('verdict-not-eligible');
|
|
108
|
+
}
|
|
109
|
+
if (typeof job.landedCommit !== 'string' || job.landedCommit.length === 0) return no('no-landed-commit');
|
|
110
|
+
if (evidenceOk !== true) return no('commit-not-from-this-run');
|
|
111
|
+
if (ancestorOk !== true) return no('commit-not-on-head');
|
|
112
|
+
if (cleanOk !== true) return no('tree-dirty');
|
|
113
|
+
if (headStable !== true) return no('head-moved-during-gate');
|
|
114
|
+
if (headOutcome?.status !== 'red') return no('head-not-red');
|
|
115
|
+
if (baseOutcome?.status === 'green') return no('base-green');
|
|
116
|
+
if (baseOutcome?.status !== 'red') return no('base-unavailable');
|
|
117
|
+
const head = failingStep(headOutcome);
|
|
118
|
+
const base = failingStep(baseOutcome);
|
|
119
|
+
if (!head || !base || head.index !== base.index) return no('different-failing-step');
|
|
120
|
+
const headIds = parseFailingTests(head.output);
|
|
121
|
+
const baseIds = parseFailingTests(base.output);
|
|
122
|
+
if (!headIds || !baseIds) return no('unparseable-output');
|
|
123
|
+
const baseSet = new Set(baseIds);
|
|
124
|
+
if (headIds.some((id) => !baseSet.has(id))) return no('new-failures');
|
|
125
|
+
return { complete: true, reason: 'baseline-identical', preexisting: headIds };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { parseFailingTests, decideBaselineAuthority };
|
|
@@ -758,6 +758,8 @@ function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
|
|
|
758
758
|
// shadow gate in flight machine-wide (the scheduler is one process).
|
|
759
759
|
let gateInFlight = false;
|
|
760
760
|
const OUTPUT_TAIL_CHARS = 1000;
|
|
761
|
+
// Bounded full-output ring kept for baseline comparison (failing test ids).
|
|
762
|
+
const OUTPUT_RING_CHARS = 64 * 1024;
|
|
761
763
|
|
|
762
764
|
function runOneGateCommand(cmd, { cwd, env }) {
|
|
763
765
|
return new Promise((resolve) => {
|
|
@@ -776,11 +778,15 @@ function runOneGateCommand(cmd, { cwd, env }) {
|
|
|
776
778
|
// No shell:true — the sequence was tokenized by tokenizeNoShell.
|
|
777
779
|
});
|
|
778
780
|
} catch {
|
|
779
|
-
resolve({ cmd: label, status: 'unavailable', code: null, ms: ms(), timedOut: false, tail: '' });
|
|
781
|
+
resolve({ cmd: label, status: 'unavailable', code: null, ms: ms(), timedOut: false, tail: '', outputTail: '' });
|
|
780
782
|
return;
|
|
781
783
|
}
|
|
782
784
|
let tail = '';
|
|
783
|
-
|
|
785
|
+
let outputTail = '';
|
|
786
|
+
const onData = (d) => {
|
|
787
|
+
tail = (tail + d).slice(-OUTPUT_TAIL_CHARS);
|
|
788
|
+
outputTail = (outputTail + d).slice(-OUTPUT_RING_CHARS);
|
|
789
|
+
};
|
|
784
790
|
child.stdout.on('data', onData);
|
|
785
791
|
child.stderr.on('data', onData);
|
|
786
792
|
let settled = false;
|
|
@@ -800,7 +806,7 @@ function runOneGateCommand(cmd, { cwd, env }) {
|
|
|
800
806
|
settled = true;
|
|
801
807
|
clearTimeout(killTimer);
|
|
802
808
|
clearTimeout(escalate);
|
|
803
|
-
resolve({ cmd: label, ms: ms(), timedOut, tail, ...r });
|
|
809
|
+
resolve({ cmd: label, ms: ms(), timedOut, tail, outputTail, ...r });
|
|
804
810
|
};
|
|
805
811
|
child.on('error', () => finish({ status: 'unavailable', code: null }));
|
|
806
812
|
child.on('close', (code) => {
|
|
@@ -60,7 +60,7 @@ function classifyLadderRung({ source, reason } = {}) {
|
|
|
60
60
|
if (s === 'spawnInvestigation:start' || s === 'spawnJob:auto-promote' || s === 'reverifyNeedsReview:auto-promote') {
|
|
61
61
|
return 'auto-fix';
|
|
62
62
|
}
|
|
63
|
-
if (s === 'reverifyNeedsReview:heal' || s === 'needsReviewAutoResolve' || s === 'gateAuthoritative') return 'reverify';
|
|
63
|
+
if (s === 'reverifyNeedsReview:heal' || s === 'needsReviewAutoResolve' || s === 'gateAuthoritative' || s === 'gateAuthority:baseline') return 'reverify';
|
|
64
64
|
// No transitionJob call site resolves needs_review with a 'quarantine'
|
|
65
65
|
// source today (performLeftoverQuarantine cleans up leftover paths without
|
|
66
66
|
// itself changing job.status — the row stays needs_review until a later
|