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.
Files changed (62) hide show
  1. package/dist/assets/{AgentLibrary-lXvlmehy.js → AgentLibrary-BLRc_YvR.js} +2 -2
  2. package/dist/assets/{Button-C6ZxRSs0.js → Button-DHEnLfDi.js} +1 -1
  3. package/dist/assets/{DataModel-CRNjAcGi.js → DataModel-C2ytUxJm.js} +1 -1
  4. package/dist/assets/{History-CBIYR5DT.js → History-eoVSG_DX.js} +2 -2
  5. package/dist/assets/{Hooks-DClb8etC.js → Hooks-rS9UvMcI.js} +3 -3
  6. package/dist/assets/{Library-CB61goZg.js → Library-CdUqaZIj.js} +7 -7
  7. package/dist/assets/{ListDetail-6mP7on1x.js → ListDetail-DhtJPQvm.js} +1 -1
  8. package/dist/assets/{McpServers-CgEhkk0x.js → McpServers-DoOcEGFd.js} +2 -2
  9. package/dist/assets/Memory-BbYwtTBZ.js +25 -0
  10. package/dist/assets/{Panel-DRVTJ70d.js → Panel-M4ouFOKI.js} +1 -1
  11. package/dist/assets/{Permissions-crk6UxUN.js → Permissions-DB0DjkgF.js} +3 -3
  12. package/dist/assets/{Plugins-DnqP6tpm.js → Plugins-DnBwH9PN.js} +2 -2
  13. package/dist/assets/ProvenanceBadge-1MQmqxxO.js +1 -0
  14. package/dist/assets/RemoteMachines-DnT08vg8.js +3 -0
  15. package/dist/assets/{SaveBar-DO1z0hF3.js → SaveBar-DGBVV6ro.js} +1 -1
  16. package/dist/assets/{Scheduler-BqFJU2sP.js → Scheduler-CGDLYpdc.js} +8 -8
  17. package/dist/assets/{ScopeSwitcher-BVlm3X8m.js → ScopeSwitcher-jgpoKdvH.js} +1 -1
  18. package/dist/assets/{Settings-CtVzhDIC.js → Settings-Ch0zc2cZ.js} +4 -4
  19. package/dist/assets/{SkillReferenceGraph-DeLStEUI.js → SkillReferenceGraph-Cd04i1nZ.js} +1 -1
  20. package/dist/assets/{Skills-D3tIYqb5.js → Skills-DLJLLulV.js} +1 -1
  21. package/dist/assets/SystemPrompt-E4VOiMJN.js +1 -0
  22. package/dist/assets/TagLibrary-2sCAw54h.js +1 -0
  23. package/dist/assets/{TiptapBody-CmYsaMda.js → TiptapBody-CzqJev6v.js} +1 -1
  24. package/dist/assets/{Toggle-CveuKhe8.js → Toggle-D5znlR_5.js} +1 -1
  25. package/dist/assets/{ViewTabs-18pC8oXm.js → ViewTabs-BENAWvOs.js} +1 -1
  26. package/dist/assets/{historyTotals-Bg6D8W2m.js → historyTotals-DsE82z-I.js} +1 -1
  27. package/dist/assets/{index-iwwBBg_v.js → index-B_3CV5Po.js} +501 -500
  28. package/dist/assets/{index-CZsDDyLy.css → index-DAMBm3UN.css} +1 -1
  29. package/dist/assets/{settingsSchema-DLcTX-tX.js → settingsSchema-CmuwD4OX.js} +1 -1
  30. package/dist/assets/{skillFrontmatter-Css435Xo.js → skillFrontmatter-CdlHJV9x.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-BYEGo9_9.js → useAgentsDirWatch-RVq9wMFY.js} +1 -1
  32. package/dist/assets/{useRemotePeers-DrjfyULz.js → useRemotePeers-Cpt47RWb.js} +1 -1
  33. package/dist/index.html +2 -2
  34. package/package.json +1 -1
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
  36. package/scripts/scheduler-mcp-server.cjs +16 -1
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/lib/feedback-remote/macroAdminRoutes.cjs +2 -1
  39. package/src/main/lib/git/gitWorktree.cjs +7 -0
  40. package/src/main/lib/git/worktreeDeps.cjs +77 -0
  41. package/src/main/lib/mcpToolCatalog.cjs +6 -2
  42. package/src/main/lib/memoryMdNetwork.cjs +121 -0
  43. package/src/main/lib/memoryRefResolve.cjs +64 -0
  44. package/src/main/lib/memoryStale.cjs +27 -11
  45. package/src/main/lib/persona-macro/macroLibrary.cjs +49 -2
  46. package/src/main/lib/scheduler/baselineGate.cjs +128 -0
  47. package/src/main/lib/scheduler/definitionOfDone.cjs +9 -3
  48. package/src/main/lib/scheduler/needsReviewLedger.cjs +1 -1
  49. package/src/main/lib/scheduler/reviewNotice.cjs +53 -0
  50. package/src/main/memoryAssist.cjs +71 -31
  51. package/src/main/memoryTool.cjs +18 -6
  52. package/src/main/promptSessionEvents.cjs +1 -1
  53. package/src/main/runVerify.cjs +39 -3
  54. package/src/main/scheduler/reviewLadder.cjs +25 -6
  55. package/src/main/scheduler.cjs +83 -1
  56. package/src/main/templates/PRD_AUTHORING.md +2 -0
  57. package/src/preload/api.d.ts +26 -3
  58. package/dist/assets/Memory-ISEb5yPN.js +0 -18
  59. package/dist/assets/ProvenanceBadge-DK2dwSU7.js +0 -1
  60. package/dist/assets/RemoteMachines-7d2sUA_3.js +0 -3
  61. package/dist/assets/SystemPrompt-vpwQTYnh.js +0 -1
  62. 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
- const KNOWN_SOURCE_EXTENSIONS = new Set([
18
- 'js', 'jsx', 'ts', 'tsx', 'cjs', 'mjs', 'json', 'md', 'py', 'rb', 'go', 'rs',
19
- 'java', 'c', 'h', 'cpp', 'hpp', 'css', 'scss', 'html', 'yml', 'yaml', 'sh',
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
- const ext = token.includes('.') ? token.split('.').pop().toLowerCase() : '';
57
- const hasSlash = token.includes('/');
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 deadRefs = candidates.filter((c) => !existsPath(c));
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
- const onData = (d) => { tail = (tail + d).slice(-OUTPUT_TAIL_CHARS); };
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