claude-code-session-manager 0.123.1 → 0.124.1

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 (80) hide show
  1. package/dist/assets/{AgentLibrary-T14aM8ie.js → AgentLibrary-qmAgadiS.js} +1 -1
  2. package/dist/assets/{Button-Dz1kV8Z_.js → Button-CkuaU6cA.js} +1 -1
  3. package/dist/assets/{DataModel-DdYRn2Ec.js → DataModel-DEVHxW5I.js} +1 -1
  4. package/dist/assets/{History-zQPvqwE7.js → History-C-qw4Gx4.js} +1 -1
  5. package/dist/assets/{Hooks-BTrbT742.js → Hooks-DzAnCyjJ.js} +1 -1
  6. package/dist/assets/{Library-BwQ6GRHR.js → Library-DExZ29wM.js} +1 -1
  7. package/dist/assets/{ListDetail-CunxAhRC.js → ListDetail-DiQk-Hf9.js} +1 -1
  8. package/dist/assets/{McpServers-DpoyxXaO.js → McpServers-C0nqX36_.js} +1 -1
  9. package/dist/assets/{Memory-KDtGYMc3.js → Memory-D4KAWPAC.js} +1 -1
  10. package/dist/assets/{Panel-xXmUBo8l.js → Panel-xv9ToSjl.js} +1 -1
  11. package/dist/assets/{Permissions-lIzTUrW2.js → Permissions-0yEyqwZU.js} +1 -1
  12. package/dist/assets/{Plugins-BA1Zqk69.js → Plugins-CB28SGGJ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CmeJG2Kw.js → ProvenanceBadge-9i4gXDvI.js} +1 -1
  14. package/dist/assets/{SaveBar-BTJg_9Uc.js → SaveBar-mSVRXpml.js} +1 -1
  15. package/dist/assets/{Scheduler-CISTDNMU.js → Scheduler-BoUetIC0.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-C6NWvzoj.js → ScopeSwitcher-B8tdQIKs.js} +1 -1
  17. package/dist/assets/{Settings-BBoGJ34v.js → Settings-D3jfKwaK.js} +1 -1
  18. package/dist/assets/{SkillReferenceGraph-IJv60QAJ.js → SkillReferenceGraph-DJ4JJRt3.js} +1 -1
  19. package/dist/assets/{Skills-L6nGcNF1.js → Skills-P9HHxDCR.js} +1 -1
  20. package/dist/assets/{SystemPrompt-Cm7I2gJz.js → SystemPrompt-B_tnbBZ2.js} +1 -1
  21. package/dist/assets/{TagLibrary-BxPwoPE6.js → TagLibrary-CI8NHRKi.js} +1 -1
  22. package/dist/assets/{TiptapBody-CR-Zr3Pz.js → TiptapBody-9LktXPiD.js} +1 -1
  23. package/dist/assets/{Toggle-BFhlpZ-n.js → Toggle-BwsUsAA7.js} +1 -1
  24. package/dist/assets/{ViewTabs-CuGlZ9Ed.js → ViewTabs-CY5gMZ_N.js} +1 -1
  25. package/dist/assets/{historyTotals-Dxv9Fzrs.js → historyTotals-_dIaxmPX.js} +1 -1
  26. package/dist/assets/{index-Dx8owDeQ.js → index-d4i3X1x3.js} +419 -419
  27. package/dist/assets/{settingsSchema-DaqFSAQH.js → settingsSchema-Dg0Xa9RR.js} +1 -1
  28. package/dist/assets/{skillFrontmatter-BIJddxVB.js → skillFrontmatter-CpoFkSyj.js} +1 -1
  29. package/dist/assets/{useAgentsDirWatch-1hjZ4knk.js → useAgentsDirWatch-CFPprfWG.js} +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist-web/assets/app.css +32 -0
  32. package/dist-web/assets/app.js +1479 -0
  33. package/dist-web/index.html +16 -0
  34. package/package.json +6 -4
  35. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +28 -10
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -6
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +68 -9
  39. package/src/main/config.cjs +23 -29
  40. package/src/main/docEdit.cjs +2 -5
  41. package/src/main/files.cjs +16 -4
  42. package/src/main/index.cjs +26 -4
  43. package/src/main/ipcSchemas.cjs +6 -2
  44. package/src/main/lib/epic/activeIndexMerge.cjs +3 -5
  45. package/src/main/lib/epic/epicReopen.cjs +161 -0
  46. package/src/main/lib/epic/epicSpawnPlan.cjs +9 -1
  47. package/src/main/lib/epic/epicTranscriptRestore.cjs +109 -0
  48. package/src/main/lib/epic/epicValidationHook.cjs +1 -1
  49. package/src/main/lib/epic/epicWorktreeMint.cjs +50 -3
  50. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +2 -1
  51. package/src/main/lib/feedback-remote/remoteAssets.cjs +98 -0
  52. package/src/main/lib/feedback-remote/remoteIpcDispatch.cjs +216 -0
  53. package/src/main/lib/insideHome.cjs +102 -23
  54. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +27 -0
  55. package/src/main/lib/prd/prdWorktreeReach.cjs +9 -1
  56. package/src/main/lib/scheduler/rcaReport.cjs +1 -13
  57. package/src/main/lib/scheduler/reaperHelpers.cjs +82 -77
  58. package/src/main/lib/scheduler/reviewNotice.cjs +0 -2
  59. package/src/main/lib/scheduler/scheduleJobTransitions.cjs +1 -1
  60. package/src/main/lib/scheduler/schedulerBatch.cjs +1 -1
  61. package/src/main/lib/sendToRenderer.cjs +22 -2
  62. package/src/main/lib/smfileHeaders.cjs +17 -1
  63. package/src/main/pluginInstall.cjs +1 -1
  64. package/src/main/promptSessionEvents.cjs +2 -5
  65. package/src/main/pty.cjs +5 -4
  66. package/src/main/runVerify.cjs +6 -41
  67. package/src/main/scheduler/budget.cjs +4 -4
  68. package/src/main/scheduler/prdParser.cjs +2 -3
  69. package/src/main/scheduler/reviewLadder.cjs +0 -9
  70. package/src/main/scheduler.cjs +72 -25
  71. package/src/main/seedAgentPersonas.cjs +1 -1
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/voiceHotkey.cjs +3 -2
  74. package/src/main/webRemote.cjs +161 -1
  75. package/src/preload/api.d.ts +8 -1
  76. package/src/preload/apiSurface.cjs +19 -0
  77. package/src/preload/apiSurface.json +1689 -0
  78. package/src/preload/index.cjs +2 -0
  79. package/src/seed/agents/architect.md +2 -2
  80. package/src/seed/agents/blender-artist.md +76 -0
@@ -0,0 +1,216 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * remoteIpcDispatch.cjs — run a remote call through the SAME ipcMain handler the
5
+ * renderer uses (no re-implementation), gated by the apiSurface remote policy and
6
+ * the web-remote tiers; plus a chunker keeping relay frames under the 256 KB cap.
7
+ *
8
+ * Pure — no Electron import. Lives in feedback-remote/ beside the other web-remote
9
+ * conduit modules (remoteClientSessions.cjs); no other lib domain fits.
10
+ * Gate order mirrors webRemote.dispatchEnvelope: kill switch → surface/allowlist →
11
+ * MUTATE tier → E2E auth.
12
+ */
13
+
14
+ const MAX_VALUE_BYTES = 50 * 1024 * 1024
15
+ const DEFAULT_FRAME_BYTES = 180000
16
+
17
+ /**
18
+ * Wrap ipcMain.handle so every registration is also stored channel -> handler.
19
+ * Original behavior is unchanged.
20
+ * @param {{ handle: (channel: string, fn: Function) => any }} ipcMain
21
+ * @returns {Map<string, Function>}
22
+ */
23
+ function captureIpcHandlers(ipcMain) {
24
+ const handlers = new Map()
25
+ const original = ipcMain.handle.bind(ipcMain)
26
+ ipcMain.handle = (channel, fn) => {
27
+ handlers.set(channel, fn)
28
+ return original(channel, fn)
29
+ }
30
+ return handlers
31
+ }
32
+
33
+
34
+ const { isContainedIn } = require('../insideHome.cjs')
35
+
36
+ /**
37
+ * Path-taking remote-readable channels. mode 'root': realpath must sit inside a
38
+ * registered project root. mode 'secret': only the secret refusal applies (the
39
+ * handler enforces its own root, e.g. the transcripts dir). `field` is the key
40
+ * of the payload object (args[0]) that carries the filesystem path.
41
+ */
42
+ const PATH_FENCED_CHANNELS = Object.freeze({
43
+ 'files:read': { field: 'path', mode: 'root' },
44
+ 'files:list': { field: 'path', mode: 'root', allowHomeListing: true },
45
+ 'transcript:readRef': { field: 'filePath', mode: 'secret' },
46
+ 'transcript:path': { field: 'cwd', mode: 'secret' },
47
+ 'memory:read': { field: 'cwd', mode: 'secret' },
48
+ })
49
+
50
+ const SECRET_DIRS = new Set(['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.password-store', '.azure', '.terraform.d'])
51
+ const SECRET_BASENAMES = new Set(['.credentials.json', '.netrc', '.npmrc', '.env', '.claude.json', '.git-credentials', '.pypirc'])
52
+ const SECRET_BASENAME_RES = [/^\.env\..*/, /\.pem$/, /^id_rsa/, /^id_ed25519/, /^id_ecdsa/]
53
+ /** Multi-segment secrets: match as consecutive path segments at any depth. */
54
+ const SECRET_SEQUENCES = [
55
+ ['.config', 'gcloud'],
56
+ ['.claude', 'session-manager'],
57
+ ['.config', 'gh'],
58
+ ['.config', 'git', 'credentials'],
59
+ ['.cargo', 'credentials'],
60
+ ['.cargo', 'credentials.toml'],
61
+ ['.local', 'share', 'keyrings'],
62
+ ['Library', 'Keychains'],
63
+ ['AppData', 'Roaming', 'Microsoft', 'Credentials'],
64
+ ['AppData', 'Local', 'Microsoft', 'Credentials'],
65
+ ]
66
+
67
+ /** True when the path has a secret segment, segment sequence or basename. */
68
+ function isSecretPath(p, platform) {
69
+ const win = platform === 'win32'
70
+ const norm = win ? String(p).toLowerCase() : String(p)
71
+ const segs = norm.split(/[\\/]+/).map((x) => (win ? x.replace(/[. ]+$/, '') : x)).filter(Boolean)
72
+ const seqs = win ? SECRET_SEQUENCES.map((q) => q.map((x) => x.toLowerCase())) : SECRET_SEQUENCES
73
+ for (let i = 0; i < segs.length; i++) {
74
+ if (SECRET_DIRS.has(segs[i])) return true
75
+ for (const q of seqs) {
76
+ if (q.every((part, k) => segs[i + k] === part)) return true
77
+ }
78
+ }
79
+ const base = segs[segs.length - 1] || ''
80
+ return SECRET_BASENAMES.has(base) || SECRET_BASENAME_RES.some((re) => re.test(base))
81
+ }
82
+
83
+ /**
84
+ * Remote-only argument fence. Returns { ok: true } or { ok: false, reason: 'path_fenced' }.
85
+ * @param {string} channel
86
+ * @param {any[]} args
87
+ * @param {{ homeDir: string, projectRoots: () => string[], realpath: (p:string)=>string, platform?: string }} ctx
88
+ */
89
+ function remoteArgFence(channel, args, ctx) {
90
+ const spec = Object.prototype.hasOwnProperty.call(PATH_FENCED_CHANNELS, channel) ? PATH_FENCED_CHANNELS[channel] : null
91
+ if (!spec) return { ok: true }
92
+ const refuse = { ok: false, reason: 'path_fenced' }
93
+ const platform = (ctx && ctx.platform) || process.platform
94
+ try {
95
+ const a0 = Array.isArray(args) ? args[0] : undefined
96
+ const raw = a0 && typeof a0 === 'object' ? a0[spec.field] : a0
97
+ if (typeof raw !== 'string' || !raw) return refuse
98
+ const real = ctx.realpath(raw)
99
+ if (isSecretPath(raw, platform) || isSecretPath(real, platform)) return refuse
100
+ if (spec.mode === 'secret') return { ok: true }
101
+ const home = ctx.realpath(ctx.homeDir)
102
+ if (spec.allowHomeListing && isContainedIn(real, home, platform) && isContainedIn(home, real, platform)) {
103
+ return { ok: true }
104
+ }
105
+ for (const root of ctx.projectRoots() || []) {
106
+ if (typeof root !== 'string' || !root) continue
107
+ let realRoot
108
+ try { realRoot = ctx.realpath(root) } catch { realRoot = root }
109
+ // A root equal to home or an ancestor of it (incl. `/` or a drive root)
110
+ // would expose every non-secret file under home — never honour it.
111
+ if (isContainedIn(home, realRoot, platform)) continue
112
+ if (isContainedIn(real, realRoot, platform)) return { ok: true }
113
+ }
114
+ return refuse
115
+ } catch {
116
+ return refuse
117
+ }
118
+ }
119
+
120
+ const STRICTNESS = { read: 0, mutate: 1, deny: 2 }
121
+
122
+ /** Strictest remote policy among the invokable (invoke|send) rows for a channel, or null. */
123
+ function policyFor(surface, channel) {
124
+ let policy = null
125
+ for (const row of surface) {
126
+ if (row.channel !== channel) continue
127
+ if (row.kind !== 'invoke' && row.kind !== 'send') continue
128
+ const p = STRICTNESS[row.remote] === undefined ? 'deny' : row.remote
129
+ if (policy === null || STRICTNESS[p] > STRICTNESS[policy]) policy = p
130
+ }
131
+ return policy
132
+ }
133
+
134
+ /**
135
+ * @param {{ surface: ReadonlyArray<{channel:string,kind:string,remote:string}>,
136
+ * handlers: Map<string, Function> | Record<string, Function>,
137
+ * getConfig: () => ({remoteEnabled?:boolean, remoteControlEnabled?:boolean} | Promise<any>),
138
+ * fenceCtx?: Parameters<typeof remoteArgFence>[2] }} deps
139
+ */
140
+ function createRemoteIpcDispatcher({ surface, handlers, getConfig, fenceCtx }) {
141
+ const lookup = (channel) =>
142
+ handlers instanceof Map ? handlers.get(channel) : handlers && Object.prototype.hasOwnProperty.call(handlers, channel) ? handlers[channel] : undefined
143
+
144
+ async function invoke(client, channel, args) {
145
+ const cfg = (await getConfig()) || {}
146
+ if (!cfg.remoteEnabled) return { ok: false, reason: 'disabled' }
147
+
148
+ const policy = typeof channel === 'string' ? policyFor(surface, channel) : null
149
+ if (policy === null) return { ok: false, reason: 'not_in_surface' }
150
+ if (policy === 'deny') return { ok: false, reason: 'denied' }
151
+
152
+ if (!client || !client.e2e || client.e2e.state !== 'authenticated') {
153
+ return { ok: false, reason: 'e2e_not_authenticated' }
154
+ }
155
+ if (policy === 'mutate' && !(cfg.remoteEnabled === true && cfg.remoteControlEnabled === true)) {
156
+ return { ok: false, reason: 'mutate_not_allowed' }
157
+ }
158
+
159
+ const handler = lookup(channel)
160
+ if (typeof handler !== 'function') return { ok: false, reason: 'no_handler' }
161
+
162
+ // Remote-only fence: fail closed when path-taking channels have no ctx.
163
+ const fence = fenceCtx || !Object.prototype.hasOwnProperty.call(PATH_FENCED_CHANNELS, channel)
164
+ ? remoteArgFence(channel, args, fenceCtx)
165
+ : { ok: false, reason: 'path_fenced' }
166
+ if (!fence.ok) return { ok: false, reason: 'path_fenced' }
167
+
168
+ try {
169
+ const event = { sender: null, remoteClientId: client.key }
170
+ const result = await handler(event, ...(Array.isArray(args) ? args : []))
171
+ return { ok: true, result }
172
+ } catch (e) {
173
+ return { ok: false, reason: 'handler_error', message: e && e.message ? String(e.message) : String(e) }
174
+ }
175
+ }
176
+
177
+ return { invoke }
178
+ }
179
+
180
+ /**
181
+ * Serialize once and split into ordered frames, each strictly under maxBytes (UTF-8).
182
+ * @returns {{ ok: true, frames: Array<{seq:number,total:number,data:string}> } | { ok: false, reason: string }}
183
+ */
184
+ function chunkFrames(value, maxBytes = DEFAULT_FRAME_BYTES) {
185
+ const json = JSON.stringify(value === undefined ? null : value)
186
+ const buf = Buffer.from(json, 'utf8')
187
+ if (buf.length > MAX_VALUE_BYTES) return { ok: false, reason: 'too_large' }
188
+ const limit = Math.max(4, maxBytes - 1)
189
+ const slices = []
190
+ let start = 0
191
+ while (start < buf.length) {
192
+ let end = Math.min(start + limit, buf.length)
193
+ if (end < buf.length) {
194
+ while (end > start && (buf[end] & 0xc0) === 0x80) end--
195
+ }
196
+ slices.push(buf.subarray(start, end).toString('utf8'))
197
+ start = end
198
+ }
199
+ if (slices.length === 0) slices.push('')
200
+ const total = slices.length
201
+ return { ok: true, frames: slices.map((data, seq) => ({ seq, total, data })) }
202
+ }
203
+
204
+ /** Reverse chunkFrames. Throws on missing/duplicate frames. */
205
+ function joinFrames(frames) {
206
+ if (!Array.isArray(frames) || frames.length === 0) throw new Error('no frames')
207
+ const sorted = [...frames].sort((a, b) => a.seq - b.seq)
208
+ const total = sorted[0].total
209
+ if (sorted.length !== total) throw new Error('incomplete frames')
210
+ sorted.forEach((f, i) => {
211
+ if (f.seq !== i || f.total !== total) throw new Error('bad frame sequence')
212
+ })
213
+ return JSON.parse(sorted.map((f) => f.data).join(''))
214
+ }
215
+
216
+ module.exports = { remoteArgFence, PATH_FENCED_CHANNELS, captureIpcHandlers, createRemoteIpcDispatcher, chunkFrames, joinFrames, MAX_VALUE_BYTES }
@@ -28,43 +28,74 @@ const fs = require('node:fs');
28
28
  const path = require('node:path');
29
29
  const os = require('node:os');
30
30
 
31
+ /**
32
+ * Canonicalize a path: native realpath (resolves 8.3 short names and subst
33
+ * drives on Windows; falls back to the JS realpath), and a not-yet-existing
34
+ * path resolves through its nearest existing ancestor with the missing tail
35
+ * joined back on. deps: { fs, platform } injectable for tests.
36
+ */
37
+ function realpath(p, deps = {}) {
38
+ const fsx = deps.fs || fs;
39
+ const pm = (deps.platform || process.platform) === 'win32' ? path.win32 : path;
40
+ const rp = typeof fsx.realpathSync.native === 'function' ? fsx.realpathSync.native : fsx.realpathSync;
41
+ const lex = pm.resolve(p);
42
+ try {
43
+ return rp(lex);
44
+ } catch (e) {
45
+ if (!e || e.code !== 'ENOENT') throw e;
46
+ }
47
+ const tail = [pm.basename(lex)];
48
+ let dir = pm.dirname(lex);
49
+ for (;;) {
50
+ try {
51
+ return pm.join(rp(dir), ...tail.reverse());
52
+ } catch (e) {
53
+ if (!e || e.code !== 'ENOENT') throw e;
54
+ const parent = pm.dirname(dir);
55
+ if (parent === dir) return lex; // filesystem root; give up safely
56
+ tail.push(pm.basename(dir));
57
+ dir = parent;
58
+ }
59
+ }
60
+ }
61
+
31
62
  // Resolve home once. Falls back to literal homedir if realpath fails
32
63
  // (which would be a misconfigured system; boot self-check in index.cjs
33
- // surfaces it). Cached for the lifetime of the process — home doesn't
34
- // move under us at runtime.
64
+ // surfaces it). Cached for the lifetime of the process.
35
65
  const HOME_LITERAL = os.homedir();
36
66
  let HOME_REAL;
37
67
  try {
38
- HOME_REAL = fs.realpathSync(HOME_LITERAL);
68
+ HOME_REAL = realpath(HOME_LITERAL);
39
69
  } catch {
40
70
  HOME_REAL = HOME_LITERAL;
41
71
  }
42
72
 
43
73
  function resolveTarget(p) {
44
- // realpath fails with ENOENT for paths that don't exist yet (writes,
45
- // mkdir destinations). Fall through to lexical resolve so the containment
46
- // check still applies — prevents info-disclosure via stat probes on
47
- // /etc/secrets/... that would be rejected later but reveal existence first.
48
- try {
49
- return fs.realpathSync(p);
50
- } catch (err) {
51
- if (err && err.code === 'ENOENT') return path.resolve(p);
52
- throw err;
53
- }
74
+ // Missing paths are validated via their nearest existing ancestor so the
75
+ // containment check still applies (no stat-probe info disclosure).
76
+ return realpath(p);
77
+ }
78
+
79
+ function isContainedWin32(target, root) {
80
+ const norm = (v) => path.win32.normalize(v).toLowerCase().replace(/[\\/]+$/, '');
81
+ const t = norm(target);
82
+ const r = norm(root);
83
+ // \\?\ and \\.\ prefixes can alias any location — never contained.
84
+ if (/^[\\/]{2}[?.][\\/]/.test(t) || /^[\\/]{2}[?.][\\/]/.test(r)) return false;
85
+ const tUnc = /^[\\/]{2}/.test(t);
86
+ const rUnc = /^[\\/]{2}/.test(r);
87
+ if (tUnc !== rUnc) return false;
88
+ return t === r || t.startsWith(r + '\\');
54
89
  }
55
90
 
56
- function isContainedWin32(target, home) {
57
- // UNC and \\?\ / \\.\ prefixes can alias any location — never inside home.
58
- if (/^[\\/]{2}/.test(target)) return false;
59
- const strip = (v) => path.win32.normalize(v).toLowerCase().replace(/[\\/]+$/, '');
60
- const t = strip(target);
61
- const h = strip(home);
62
- return t === h || t.startsWith(h + '\\');
91
+ /** Is realPath equal to, or beneath, root? Equality-or-separator boundary. */
92
+ function isContainedIn(realPath, root, platform = process.platform) {
93
+ if (platform === 'win32') return isContainedWin32(realPath, root);
94
+ return realPath === root || realPath.startsWith(root + path.posix.sep);
63
95
  }
64
96
 
65
97
  function isContained(realPath, home = HOME_REAL, platform = process.platform) {
66
- if (platform === 'win32') return isContainedWin32(realPath, home);
67
- return realPath === home || realPath.startsWith(home + path.posix.sep);
98
+ return isContainedIn(realPath, home, platform);
68
99
  }
69
100
 
70
101
  function assertInsideHome(p) {
@@ -99,4 +130,52 @@ function checkInsideHome(p) {
99
130
  return { ok: true, realPath };
100
131
  }
101
132
 
102
- module.exports = { assertInsideHome, checkInsideHome, isContained };
133
+ /**
134
+ * Home OR a main-process-registered project root. `roots` must come from
135
+ * config.cjs's allowed-roots registry — never from a renderer request.
136
+ * deps: { platform, home, fs } injectable for tests.
137
+ */
138
+ function checkInsideHomeOrRoots(p, roots = [], deps = {}) {
139
+ const platform = deps.platform || process.platform;
140
+ const home = deps.home || HOME_REAL;
141
+ const list = [...roots];
142
+ const fail = () => ({
143
+ ok: false,
144
+ error: `path outside home and project roots: ${typeof p === 'string' ? p : String(p)}`,
145
+ });
146
+ if (typeof p !== 'string' || !p) return fail();
147
+ let realPath;
148
+ try {
149
+ realPath = realpath(p, deps);
150
+ } catch {
151
+ return fail();
152
+ }
153
+ if (isContainedIn(realPath, home, platform)) return { ok: true, realPath };
154
+ for (const root of list) {
155
+ if (typeof root !== 'string' || !root) continue;
156
+ let realRoot;
157
+ try {
158
+ realRoot = realpath(root, deps);
159
+ } catch {
160
+ realRoot = root;
161
+ }
162
+ if (isContainedIn(realPath, realRoot, platform)) return { ok: true, realPath };
163
+ }
164
+ return fail();
165
+ }
166
+
167
+ function assertInsideHomeOrRoots(p, roots = [], deps = {}) {
168
+ const r = checkInsideHomeOrRoots(p, roots, deps);
169
+ if (!r.ok) throw new Error(r.error);
170
+ return { realPath: r.realPath };
171
+ }
172
+
173
+ module.exports = {
174
+ assertInsideHome,
175
+ checkInsideHome,
176
+ assertInsideHomeOrRoots,
177
+ checkInsideHomeOrRoots,
178
+ isContained,
179
+ isContainedIn,
180
+ realpath,
181
+ };
@@ -51,6 +51,18 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
51
51
  tools: "Read, Grep, Glob, Bash, Edit, Write",
52
52
  },
53
53
  },
54
+ {
55
+ commit: "0c6e625f",
56
+ bodySha256: "40a4d41445e7fdb128f4f1d8d3eef88b857ca466726b3719b36cb69933f09168",
57
+ fm: {
58
+ description: "The primary Actor for an Epic's whole interactive conversation — owns overall plan and decomposition, clarifies scope, searches before building, decomposes work into scheduled PRDs via /develop, tracks them to completion, and verifies before calling anything done. Never implements a PRD itself — that's dev-lead's job, one PRD at a time, headless. Task-type framing is the Epic's Mission tag's job, not this persona's.",
59
+ model: "opus",
60
+ effort: "medium",
61
+ tags: "feature, bug, discussion, art",
62
+ title: "Engineering — Architect",
63
+ tools: "Read, Grep, Glob, Bash, Edit, Write",
64
+ },
65
+ },
54
66
  ],
55
67
  "dev-lead": [
56
68
  {
@@ -331,6 +343,21 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
331
343
  },
332
344
  },
333
345
  ],
346
+ "blender-artist": [
347
+ {
348
+ commit: "0c6e625f",
349
+ bodySha256: "9cc52c7a1e35f81a158cccfd6b05974ed7a95e23cb98666c8f68dab54c9a6b5a",
350
+ fm: {
351
+ description: "Expert 3D character artist for games — modeling, rigging, skinning, shape keys and keyframe animation in Blender, driven headlessly through Blender's Python API and the Blender MCP, verified in-engine with the Godot CLI. Executes exactly one already-scoped art PRD at a time (the dev-lead equivalent for 3D assets). Use for any change whose source of truth is a .blend file.",
352
+ tools: "Read, Grep, Glob, Bash, Edit, Write, mcp__blender__execute_blender_code_for_cli, mcp__blender__get_blendfile_summary_datablocks_for_cli, mcp__blender__get_blendfile_summary_usage_guess_for_cli, mcp__blender__get_blendfile_summary_missing_files_for_cli, mcp__blender__get_python_api_docs, mcp__blender__search_api_docs, mcp__blender__search_manual_docs, mcp__blender__render_thumbnail_to_path, mcp__blender__render_viewport_to_path, mcp__blender__execute_blender_code, mcp__blender__get_objects_summary, mcp__blender__get_object_detail_summary, mcp__blender__get_screenshot_of_area_as_image",
353
+ title: "Games — 3D Character Artist (Blender)",
354
+ model: "opus",
355
+ effort: "high",
356
+ tags: "art",
357
+ executorOnly: "true",
358
+ },
359
+ },
360
+ ],
334
361
  });
335
362
 
336
363
  module.exports = { SHIPPED_PERSONA_SEEDS };
@@ -22,6 +22,9 @@ const MAX_NAMED_PATHS = 5;
22
22
  const LEADING_JUNK_RE = /^[`'"([<]+/;
23
23
  const TRAILING_JUNK_RE = /[`'")\]>.,:;]+$/;
24
24
 
25
+ const FRAGMENT_RE = /#[^/#]*$/;
26
+ const LINE_SUFFIX_RE = /:\d+(?:-\d+)?$/;
27
+
25
28
  function isCandidate(token) {
26
29
  if (!token.includes('/')) return false;
27
30
  if (/^[/~$-]/.test(token)) return false;
@@ -35,7 +38,12 @@ function extractRepoPathCandidates(text) {
35
38
  const seen = new Set();
36
39
  for (const raw of String(text == null ? '' : text).split(/\s+/)) {
37
40
  if (!raw) continue;
38
- const token = raw.replace(LEADING_JUNK_RE, '').replace(TRAILING_JUNK_RE, '');
41
+ const token = raw
42
+ .replace(LEADING_JUNK_RE, '')
43
+ .replace(TRAILING_JUNK_RE, '')
44
+ .replace(FRAGMENT_RE, '')
45
+ .replace(LINE_SUFFIX_RE, '')
46
+ .replace(TRAILING_JUNK_RE, '');
39
47
  if (!token || !isCandidate(token)) continue;
40
48
  seen.add(token);
41
49
  if (seen.size >= MAX_CANDIDATES) break;
@@ -73,7 +73,6 @@ const FAILURE_CLASSES = {
73
73
  TRANSCRIPT_ERRORS: 'transcript-errors',
74
74
  ABANDONED_BACKGROUND_TASK: 'abandoned-background-task',
75
75
  BLOCKED_BY_FOREIGN_WIP: 'blocked-by-foreign-wip',
76
- NEEDS_HUMAN: 'needs-human',
77
76
  UNKNOWN: 'unknown',
78
77
  };
79
78
 
@@ -83,10 +82,7 @@ const FAILURE_CLASSES = {
83
82
  // nothing to investigate, and re-queuing is already owned by
84
83
  // reconcile()'s requeueForeignWipBlockedJobs, not this action), 'resume-and-commit'
85
84
  // (PRD 1111's --resume dispatch owns this), 'verify-and-close' (the work
86
- // likely landed, just missing its sentinel), 'ask-human' (the executor
87
- // correctly stopped on a step only a person can do — answer the job's
88
- // humanQuestion, then update + reset the PRD; never a fix-plan, and the review
89
- // ladder never auto-fixes it), 'investigate' (the only class that still buys a
85
+ // likely landed, just missing its sentinel), 'investigate' (the only class that still buys a
90
86
  // fix-plan investigation).
91
87
  const RECOVERY_ACTIONS = {
92
88
  [FAILURE_CLASSES.ALREADY_SHIPPED]: 'archive',
@@ -108,7 +104,6 @@ const RECOVERY_ACTIONS = {
108
104
  // guidance into that cold-read investigation.
109
105
  [FAILURE_CLASSES.ABANDONED_BACKGROUND_TASK]: 'investigate',
110
106
  [FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]: 'archive',
111
- [FAILURE_CLASSES.NEEDS_HUMAN]: 'ask-human',
112
107
  [FAILURE_CLASSES.UNKNOWN]: 'investigate',
113
108
  };
114
109
 
@@ -136,8 +131,6 @@ const PREVENTION_HINTS = {
136
131
  'A headless run cannot receive a background-task completion notification — never let a long Bash command auto-background past its foreground timeout and then wait for it. Run long commands with an explicit bound (`timeout <N> <cmd>`) so they finish in the foreground, or poll their output file directly instead of waiting on Monitor/notification.',
137
132
  [FAILURE_CLASSES.BLOCKED_BY_FOREIGN_WIP]:
138
133
  'This job correctly reported `SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP` against a sibling job\'s in-flight, uncommitted files three times in a row — auto-requeue is exhausted (reconcile() only clears this once the blocked paths go clean). This is not a defect in this PRD\'s own work: check on the sibling job/human that owns the still-dirty path(s) named on the job row, and either wait for them to commit or manually reset this job to `pending` once the tree is clear. Do not author a fix-plan PRD against it.',
139
- [FAILURE_CLASSES.NEEDS_HUMAN]:
140
- 'The run stopped on a step that needs a person or live runtime state — the executor was right to stop, so this is not a defect to fix-plan. Answer the job\'s humanQuestion, then update and reset the PRD. If the reason is that the job worktree lacks gitignored runtime state (databases, browser profile, downloads/, crontab paths), re-author the step as an artifact-only PRD that addresses the main checkout by absolute path, per PRD_AUTHORING.md section 7.',
141
134
  [FAILURE_CLASSES.UNKNOWN]:
142
135
  'Re-run the acceptance criteria gate locally against the failure log to pin down the specific break before re-queuing.',
143
136
  };
@@ -199,11 +192,6 @@ function classifyFailure({ verdict, logTail: rawLogTail }) {
199
192
  const logTail = stripHarnessInitEvents(rawLogTail);
200
193
  const lines = logTail.split('\n');
201
194
 
202
- // Checked before every other rule, log-tail heuristics included: the
203
- // executor deliberately stopped on a human-only step, so words like
204
- // "sleep"/"until" in its tail say nothing about the cause.
205
- if (verdict === 'needs_human') return FAILURE_CLASSES.NEEDS_HUMAN;
206
-
207
195
  // Checked before every other rule: an unambiguous, materially-checked
208
196
  // verdict (the scheduler itself validated the executor's claimed paths
209
197
  // against the job's disclosed foreign-WIP manifest before ever landing