claude-code-session-manager 0.92.0 → 0.93.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/DataModel-DDGx3qrn.js +1 -0
- package/dist/assets/{History-BB6wpf02.js → History-CHBjYZyD.js} +2 -2
- package/dist/assets/{Hooks-zQZH56Sv.js → Hooks-DG4c-Rzz.js} +3 -3
- package/dist/assets/{HostBilko-CW0pHoPS.js → HostBilko-DHXcpV2c.js} +1 -1
- package/dist/assets/{Library-DrGdaz9N.js → Library-CN1h5rAE.js} +1 -1
- package/dist/assets/{MarkdownEditor-H1ytN4Tk.js → MarkdownEditor-DmBxrhdn.js} +1 -1
- package/dist/assets/{McpServers-CYSRCI-j.js → McpServers-Beb_HD6I.js} +2 -2
- package/dist/assets/{Memory-zMNpJyRU.js → Memory-Dk4uDCjg.js} +4 -4
- package/dist/assets/{Permissions-Be6cIkPg.js → Permissions-BovobeZw.js} +3 -3
- package/dist/assets/Plugins-DvOf_B62.js +2 -0
- package/dist/assets/ProvenanceBadge-Dutz_4OY.js +1 -0
- package/dist/assets/{SaveBar-xQZ2EBOn.js → SaveBar-CT5u6ZCJ.js} +1 -1
- package/dist/assets/Scheduler-B7GSnZj5.js +16 -0
- package/dist/assets/{ScopeSwitcher-BAfquN6Y.js → ScopeSwitcher-BUYDpz2v.js} +1 -1
- package/dist/assets/Settings-DIRmr3tL.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BI1YtCbB.js → SkillReferenceGraph-CdGr6d3_.js} +1 -1
- package/dist/assets/Skills-BQOheQv4.js +3 -0
- package/dist/assets/SystemPrompt-5LsimKbX.js +1 -0
- package/dist/assets/TagLibrary-DreLsT3n.js +1 -0
- package/dist/assets/{TiptapBody-BptXAVR9.js → TiptapBody-C9kmlBnI.js} +1 -1
- package/dist/assets/{Toggle-BwiClCSI.js → Toggle-BKLCBu9A.js} +1 -1
- package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
- package/dist/assets/index-Db_SK9rW.js +3079 -0
- package/dist/assets/settingsSchema-B5jBhSUP.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
- package/src/main/__tests__/agentLibrary.test.cjs +21 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
- package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
- package/src/main/__tests__/personaMerge.test.cjs +169 -0
- package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +75 -10
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
- package/src/main/agentLibrary.cjs +86 -40
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +5 -0
- package/src/main/index.cjs +10 -0
- package/src/main/ipcSchemas.cjs +12 -0
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
- package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
- package/src/main/lib/agentEffortResolve.cjs +74 -0
- package/src/main/lib/agentModelResolve.cjs +52 -59
- package/src/main/lib/agentPersonaSchema.cjs +3 -0
- package/src/main/lib/effectiveModelInfo.cjs +38 -20
- package/src/main/lib/epicMint.cjs +5 -2
- package/src/main/lib/epicWorktreeMerge.cjs +7 -6
- package/src/main/lib/modelCatalog.cjs +243 -0
- package/src/main/lib/personaMerge.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +23 -5
- package/src/main/lib/prdDisposition.cjs +46 -0
- package/src/main/lib/prdFrontmatter.cjs +6 -2
- package/src/main/lib/promptSessionSchema.cjs +3 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/pty.cjs +16 -2
- package/src/main/scheduler/prdParser.cjs +2 -0
- package/src/main/scheduler.cjs +21 -5
- package/src/main/templates/PRD_AUTHORING.md +4 -0
- package/src/preload/api.d.ts +53 -2
- package/src/preload/index.cjs +4 -0
- package/dist/assets/AgentLibrary-Bu-1eT_n.js +0 -3
- package/dist/assets/DataModel-Bk9rkJ8x.js +0 -1
- package/dist/assets/ListDetail-D4bxU_-2.js +0 -1
- package/dist/assets/Panel-RMz3Acpu.js +0 -1
- package/dist/assets/Plugins-B8sTWf2K.js +0 -2
- package/dist/assets/ProvenanceBadge-CO-DZN38.js +0 -1
- package/dist/assets/Scheduler-CF3zsJBw.js +0 -16
- package/dist/assets/Settings-uPjmqutA.js +0 -3
- package/dist/assets/Skills-BEy9FDYd.js +0 -3
- package/dist/assets/SystemPrompt-BYkkw-LL.js +0 -1
- package/dist/assets/TagLibrary-Kg3yOC5b.js +0 -1
- package/dist/assets/index-a2MVk8qh.js +0 -3074
- package/dist/assets/settingsSchema-DgxlStF5.js +0 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ay as b,aj as n,S as N,aN as O,aw as K}from"./index-Db_SK9rW.js";function T({node:e,targetScope:r,onOverride:s,schema:a,rootLabel:m}){const[f,x]=b.useState(""),[u,o]=b.useState(!0),g=[],d=e.kind==="object"&&Object.keys(e.children).length===0,y=((a==null?void 0:a.rootKeys())??[]).length>0;return n.jsxs("div",{className:"h-full flex flex-col",children:[n.jsxs("div",{className:"shrink-0 border-b border-line bg-bg-elev px-3 py-1.5 flex items-center gap-2 text-xs",children:[n.jsxs("span",{className:"text-fg-faint",children:["merged · overrides → ",n.jsx("span",{className:"text-fg-dim",children:N[r].label})]}),y&&n.jsxs("label",{className:"flex items-center gap-1 text-fg-faint cursor-pointer",children:[n.jsx("input",{type:"checkbox",checked:u,onChange:t=>o(t.target.checked),className:"accent-accent"}),"show unset keys"]}),n.jsx("div",{className:"flex-1"}),n.jsx("input",{value:f,onChange:t=>x(t.target.value),placeholder:"filter keys or descriptions…",className:"bg-bg border border-line rounded px-2 py-0.5 text-xs text-fg w-56"})]}),n.jsx("div",{className:"flex-1 overflow-auto px-3 py-2 font-mono text-xs",children:d&&!u?n.jsx("div",{className:"text-fg-faint italic px-2 py-4",children:"no settings resolved — all scope files empty or missing"}):n.jsx(E,{node:e,path:g,label:m??"(root)",targetScope:r,onOverride:s,filter:f,depth:0,schema:a,showGhosts:u,defaultOpen:!0})})]})}function E(e){return e.node.kind==="object"?n.jsx(P,{...e,node:e.node}):e.node.kind==="array"?n.jsx(J,{...e,node:e.node}):n.jsx(R,{...e,node:e.node})}function A(e,r){return r.trim()?e.toLowerCase().includes(r.toLowerCase()):!0}function w(e,r,s){return!!(!s.trim()||e.some(a=>A(a,s))||r!=null&&r.description&&A(r.description,s))}function P({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u,showGhosts:o,defaultOpen:g}){const[d,h]=b.useState(g??x<2),y=(u==null?void 0:u.at(r))??null,t=new Set(Object.keys(e.children)),i=o?(y==null?void 0:y.knownChildren)??[]:[],c=Array.from(new Set([...t,...i])).sort(),l=b.useMemo(()=>f.trim()?c.filter(p=>{const v=(u==null?void 0:u.at([...r,p]))??null;return w([...r,p],v,f)?!0:t.has(p)?L(e.children[p],[...r,p],f,u):!1}):c,[c.join(","),f,e,r.join("."),u]);if(f.trim()&&l.length===0&&!w(r,y,f))return null;if(r.length===0)return n.jsx("div",{children:l.map(p=>j(p))});return n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:x*14},onClick:()=>h(!d),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:d?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" {",t.size,i.length>t.size?`/${new Set([...t,...i]).size}`:"","}"]}),(y==null?void 0:y.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:y.description,children:["— ",y.description]})]}),d&&l.map(p=>j(p))]});function j(p){if(t.has(p))return n.jsx(E,{node:e.children[p],path:[...r,p],label:p,targetScope:a,onOverride:m,filter:f,depth:x+1,schema:u,showGhosts:o},p);const v=(u==null?void 0:u.at([...r,p]))??null;return v?n.jsx(I,{path:[...r,p],label:p,info:v,targetScope:a,onSet:m,depth:x+1,filter:f},p):null}}function L(e,r,s,a){const m=(a==null?void 0:a.at(r))??null;if(w(r,m,s))return!0;if(e.kind!=="object")return!1;for(const f of Object.keys(e.children))if(L(e.children[f],[...r,f],s,a))return!0;return!1}function J({node:e,path:r,label:s,filter:a,depth:m,schema:f}){const[x,u]=b.useState(!0),o=(f==null?void 0:f.at(r))??null;return w(r,o,a)?n.jsxs("div",{children:[n.jsxs("div",{className:"flex items-center gap-1 py-0.5 hover:bg-bg-elev/50 rounded cursor-pointer",style:{paddingLeft:m*14},onClick:()=>u(!x),children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0",children:x?"▾":"▸"}),n.jsx("span",{className:"text-fg",children:s}),n.jsxs("span",{className:"text-fg-faint",children:[" [",e.items.length,"]"]}),n.jsx(S,{tone:"warn",children:"accumulated"}),(o==null?void 0:o.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:o.description,children:["— ",o.description]})]}),x&&e.items.map((g,d)=>n.jsxs("div",{className:"flex items-start gap-2 py-0.5",style:{paddingLeft:(m+1)*14},children:[n.jsx("span",{className:"text-fg-faint w-3 shrink-0"}),n.jsxs("span",{className:"text-fg-faint",children:[d,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-xl",children:k(g.value)}),n.jsx(C,{scope:g.fromScope})]},d))]}):null}function R({node:e,path:r,label:s,targetScope:a,onOverride:m,filter:f,depth:x,schema:u}){const[o,g]=b.useState(!1),d=(u==null?void 0:u.at(r))??null;if(!w(r,d,f))return null;const h=e.winner!==a,y=e.shadowed.length>0;return n.jsxs("div",{children:[n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded",style:{paddingLeft:x*14},children:[n.jsx("span",{className:`w-3 shrink-0 ${y?"text-fg-faint cursor-pointer":""}`,onClick:()=>y&&g(!o),children:y?o?"▾":"▸":""}),n.jsxs("span",{className:"text-fg",children:[s,":"]}),n.jsx("span",{className:"text-fg-dim truncate max-w-md",children:k(e.value)}),n.jsx(C,{scope:e.winner}),(d==null?void 0:d.deprecated)&&n.jsx(S,{tone:"warn",children:"deprecated"}),(d==null?void 0:d.default)!==void 0&&_(e.value,d.default)&&n.jsx(S,{tone:"dim",children:"= default"}),e.typeMismatch&&n.jsx("span",{className:"text-yellow-500/80",title:"shadowed scopes have a different value type",children:"⚠"}),(d==null?void 0:d.description)&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:d.description,children:["— ",d.description]}),n.jsx("div",{className:"flex-1"}),(d==null?void 0:d.enum)&&n.jsx(F,{values:d.enum}),n.jsx("button",{disabled:!h,onClick:()=>m(r,e.value),className:`opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border rounded transition-opacity ${h?"border-accent/50 text-accent hover:bg-accent hover:text-bg":"border-line text-fg-faint cursor-not-allowed"}`,title:h?`copy into ${N[a].label} draft`:`already winning in ${N[a].label}`,children:"override"})]}),o&&e.shadowed.map((t,i)=>n.jsxs("div",{className:"flex items-center gap-2 py-0.5 text-fg-faint line-through",style:{paddingLeft:(x+1)*14+12},children:[n.jsx("span",{children:"shadowed:"}),n.jsx("span",{className:"truncate max-w-xl",children:k(t.value)}),n.jsx(C,{scope:t.scope,dim:!0})]},i))]})}function I({path:e,label:r,info:s,targetScope:a,onSet:m,depth:f,filter:x}){if(!w(e,s,x))return null;const u=s.default!==void 0?s.default:M(s.type),o=Array.isArray(s.type)?s.type.join("|"):s.type??"?";return n.jsxs("div",{className:"group flex items-center gap-2 py-0.5 hover:bg-bg-elev/50 rounded opacity-60",style:{paddingLeft:f*14},children:[n.jsx("span",{className:"w-3 shrink-0"}),n.jsx("span",{className:"text-fg-dim",children:r}),n.jsx(S,{tone:"dim",children:o}),s.required&&n.jsx(S,{tone:"warn",children:"required"}),s.default!==void 0&&n.jsxs("span",{className:"text-fg-faint",children:["default: ",k(s.default)]}),s.description&&n.jsxs("span",{className:"text-fg-faint truncate max-w-md",title:s.description,children:["— ",s.description]}),n.jsx("div",{className:"flex-1"}),s.enum&&n.jsx(F,{values:s.enum}),n.jsx("button",{onClick:()=>m(e,u),className:"opacity-0 group-hover:opacity-100 px-1.5 py-0 text-[10px] border border-accent/50 rounded text-accent hover:bg-accent hover:text-bg transition-opacity",title:`write to ${N[a].label} draft`,children:"set"})]})}function _(e,r){return e===r?!0:typeof e!=typeof r?!1:e&&r&&typeof e=="object"?JSON.stringify(e)===JSON.stringify(r):!1}function M(e){switch(Array.isArray(e)?e[0]:e){case"boolean":return!1;case"integer":case"number":return 0;case"string":return"";case"array":return[];case"object":return{};case"null":return null;default:return null}}function k(e){if(typeof e=="string")return JSON.stringify(e);if(e===null)return"null";if(typeof e=="object"){const r=JSON.stringify(e);return r.length>100?r.slice(0,100)+"…":r}return String(e)}function C({scope:e,dim:r}){const s=e==="user"?"bg-bg-hi text-fg-dim border-line":e==="project"?"bg-blue-950/30 text-blue-400/80 border-blue-900/40":"bg-purple-950/30 text-purple-400/80 border-purple-900/40";return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s} ${r?"opacity-60":""}`,children:N[e].label})}function S({children:e,tone:r="default"}){const s={default:"bg-bg-hi text-fg-dim border-line",warn:"bg-yellow-950/30 text-yellow-500/80 border-yellow-900/40",dim:"bg-bg-elev text-fg-faint border-line"}[r];return n.jsx("span",{className:`text-[10px] px-1 py-0 rounded border uppercase tracking-wide shrink-0 ${s}`,children:e})}function F({values:e}){const r=e.map(s=>String(s)).join(" | ");return n.jsxs("span",{className:"text-[10px] text-fg-faint truncate max-w-xs",title:`allowed: ${r}`,children:["∈ ",r.length>30?r.slice(0,30)+"…":r]})}function G(e,r,s){const a=O(o=>o.files),m=O(o=>o.loadJson),f=O(o=>o.watchFile),x=O(o=>o.unwatchFile),u=b.useMemo(()=>{const o={};for(const g of e.scopes){const d=e.resolve(g,r??"",s);d&&(o[g]=d)}return o},[e,r,s]);return b.useEffect(()=>{const o=Object.values(u).filter(Boolean);return o.forEach(g=>{a[g]||m(g),f(g)}),()=>{o.forEach(g=>x(g))}},[JSON.stringify(u)]),u}function B(e){const r=e??{},s={...r.$defs??{},...r.definitions??{}},a=t=>{if(!t)return{};if(!t.$ref)return x(t);const i=/^#\/(\$defs|definitions)\/(.+)$/.exec(t.$ref);if(!i)return x(t);const c=s[i[2]];return c?a({...c,...u(t)}):x(t)},m=new Set(["__proto__","constructor","prototype"]),f=t=>{if(!t)return{};const i={};for(const[c,l]of Object.entries(t))m.has(c)||(i[c]=l);return i},x=t=>{if(!t.allOf||t.allOf.length===0)return t;const i={...t};delete i.allOf;for(const c of t.allOf){const l=a(c);Object.assign(i,{properties:{...f(i.properties),...f(l.properties)},required:q([...i.required??[],...l.required??[]])}),!i.description&&l.description&&(i.description=l.description),i.default===void 0&&l.default!==void 0&&(i.default=l.default),!i.type&&l.type&&(i.type=l.type)}return i},u=t=>{const{$ref:i,...c}=t;return c},o=(t,i)=>{let c=a(t);for(const l of i){if(!c)return null;if(/^\d+$/.test(l)){const v=Array.isArray(c.items)?c.items[Number(l)]:c.items;c=v?a(v):null;continue}const j=c.properties;if(j&&!m.has(l)&&l in j){c=a(j[l]);continue}const p=c.additionalProperties;if(p&&typeof p=="object"){c=a(p);continue}return null}return c},g=(t,i,c)=>{if(!t)return null;const l={};if(t.description&&(l.description=t.description),t.type&&(l.type=t.type),t.default!==void 0&&(l.default=t.default),t.enum&&(l.enum=t.enum),t.const!==void 0&&(l.const=t.const),t.deprecated&&(l.deprecated=!0),t.examples&&(l.examples=t.examples),t.properties&&(l.knownChildren=Object.keys(t.properties)),c&&(i!=null&&i.includes(c))&&(l.required=!0),t.items&&!Array.isArray(t.items)){const j=a(t.items),p=g(j,void 0,null);p&&(l.itemsInfo=p)}return(t.additionalProperties===!0||typeof t.additionalProperties=="object")&&(l.open=!0),l},d=t=>i=>{if(i.length===0)return g(a(t),void 0,null);const c=o(t,i.slice(0,-1));if(!c)return null;const l=i[i.length-1],j=o(c,[l]);return g(j,c.required,l)},h=t=>{const i=a(t);return Object.keys(i.properties??{})},y=t=>({at:d(t),rootKeys:()=>h(t),rootedAt:i=>{const c=o(t,i);return y(c??{})}});return y(r)}function q(e){return Array.from(new Set(e))}let $=null;function V(){return $||($=B(K)),$}export{T as E,V as s,G as u};
|
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-Db_SK9rW.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CKH5Uxik.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentEffortResolve.test.cjs — the effort twin of agentModelResolve.test.cjs:
|
|
3
|
+
* persona `effort:` wins (overlay beats global), inherit/absent/dangling → no flag.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/agentEffortResolve.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const fsp = require('node:fs/promises');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { resolveEpicEffort, effortArgs } = require('../lib/agentEffortResolve.cjs');
|
|
16
|
+
|
|
17
|
+
const tmpDirs = [];
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function mkTmpDir(prefix) {
|
|
23
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
24
|
+
tmpDirs.push(dir);
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeIndex(cwd, sessions) {
|
|
29
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writePersona(dir, name, frontmatter) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), ['---', `name: ${name}`, ...frontmatter, '---', 'body'].join('\n'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const noopValidatePath = (p) => p;
|
|
40
|
+
|
|
41
|
+
test('persona effort wins (global) for a matching Epic', async () => {
|
|
42
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
43
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
44
|
+
writeIndex(cwd, { e1: { id: 'e1', claudeSessionId: 's1', agentType: 'p' } });
|
|
45
|
+
writePersona(globalDir, 'p', ['effort: high']);
|
|
46
|
+
expect(resolveEpicEffort({ cwd, claudeSessionId: 's1', deps: { globalDir, validatePath: noopValidatePath } }))
|
|
47
|
+
.toEqual({ effort: 'high', source: 'persona' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('project overlay beats global', async () => {
|
|
51
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
52
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
53
|
+
const projectDir = await mkTmpDir('sm-effort-proj-');
|
|
54
|
+
writePersona(globalDir, 'p', ['effort: low']);
|
|
55
|
+
writePersona(projectDir, 'p', ['effort: max']);
|
|
56
|
+
expect(resolveEpicEffort({ cwd, agentType: 'p', deps: { globalDir, projectDir, validatePath: noopValidatePath } }))
|
|
57
|
+
.toEqual({ effort: 'max', source: 'persona-overlay' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('inherit and absent resolve to null effort', async () => {
|
|
61
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
62
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
63
|
+
writePersona(globalDir, 'inh', ['effort: inherit']);
|
|
64
|
+
writePersona(globalDir, 'none', []);
|
|
65
|
+
const deps = { globalDir, validatePath: noopValidatePath };
|
|
66
|
+
expect(resolveEpicEffort({ cwd, agentType: 'inh', deps })).toEqual({ effort: null, source: 'inherit' });
|
|
67
|
+
expect(resolveEpicEffort({ cwd, agentType: 'none', deps })).toEqual({ effort: null, source: 'inherit' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a persona effort of `auto` (a /effort reset verb) resolves to no flag', async () => {
|
|
71
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
72
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
73
|
+
writePersona(globalDir, 'au', ['effort: auto']);
|
|
74
|
+
const deps = { globalDir, validatePath: noopValidatePath };
|
|
75
|
+
const r = resolveEpicEffort({ cwd, agentType: 'au', deps });
|
|
76
|
+
expect(r.effort).toBeNull();
|
|
77
|
+
expect(effortArgs(r.effort)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('dangling agentType / unknown Epic / empty opts resolve to null without throwing', async () => {
|
|
81
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
82
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
83
|
+
writeIndex(cwd, {});
|
|
84
|
+
const deps = { globalDir, validatePath: noopValidatePath };
|
|
85
|
+
expect(resolveEpicEffort({ cwd, agentType: 'gone', deps })).toEqual({ effort: null, source: null });
|
|
86
|
+
expect(resolveEpicEffort({ cwd, claudeSessionId: 'nope', deps })).toEqual({ effort: null, source: null });
|
|
87
|
+
expect(resolveEpicEffort({})).toEqual({ effort: null, source: null });
|
|
88
|
+
expect(resolveEpicEffort({ cwd, agentType: '../../etc/passwd', deps })).toEqual({ effort: null, source: null });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('an unknown level is passed through unchanged (CLI warns and degrades)', async () => {
|
|
92
|
+
const cwd = await mkTmpDir('sm-effort-cwd-');
|
|
93
|
+
const globalDir = await mkTmpDir('sm-effort-agents-');
|
|
94
|
+
writePersona(globalDir, 'odd', ['effort: turbo']);
|
|
95
|
+
expect(resolveEpicEffort({ cwd, agentType: 'odd', deps: { globalDir, validatePath: noopValidatePath } }).effort).toBe('turbo');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('effortArgs emits the pair only for a real level', () => {
|
|
99
|
+
expect(effortArgs('high')).toEqual(['--effort', 'high']);
|
|
100
|
+
for (const v of [null, undefined, '', 'inherit']) expect(effortArgs(v)).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Epic-level override (New Session card) > persona frontmatter > no flag.
|
|
104
|
+
test('an Epic-level effort override beats the persona effort; an Epic without the field resolves unchanged', async () => {
|
|
105
|
+
const cwd = await mkTmpDir('sm-effort-ovr-cwd-');
|
|
106
|
+
const globalDir = await mkTmpDir('sm-effort-ovr-agents-');
|
|
107
|
+
writeIndex(cwd, {
|
|
108
|
+
withOverride: { id: 'withOverride', claudeSessionId: 's-ovr', agentType: 'p', effort: 'max' },
|
|
109
|
+
legacy: { id: 'legacy', claudeSessionId: 's-old', agentType: 'p' },
|
|
110
|
+
noPersona: { id: 'noPersona', claudeSessionId: 's-np', effort: 'low' },
|
|
111
|
+
});
|
|
112
|
+
writePersona(globalDir, 'p', ['effort: low']);
|
|
113
|
+
const deps = { globalDir, validatePath: noopValidatePath };
|
|
114
|
+
expect(resolveEpicEffort({ cwd, claudeSessionId: 's-ovr', deps })).toEqual({ effort: 'max', source: 'epic' });
|
|
115
|
+
expect(resolveEpicEffort({ cwd, claudeSessionId: 's-old', deps })).toEqual({ effort: 'low', source: 'persona' });
|
|
116
|
+
expect(resolveEpicEffort({ cwd, claudeSessionId: 's-np', deps })).toEqual({ effort: 'low', source: 'epic' });
|
|
117
|
+
});
|
|
@@ -253,3 +253,24 @@ test('a persona with no Action fields reports them as empty/null rather than und
|
|
|
253
253
|
expect(p.action).toBeNull();
|
|
254
254
|
expect(p.actionLabel).toBeNull();
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
test('effort: written when set, omitted for inherit/unset, round-trips, and untouched personas gain no line', async () => {
|
|
258
|
+
const globalDir = await mkTmp('sm-agent-library-effort-');
|
|
259
|
+
const base = { tools: [], color: '', tags: [], body: 'B.', globalDir, validatePath: identityValidatePath, writeTextAtomic: fakeWriteTextAtomic };
|
|
260
|
+
await savePersona({ ...base, name: 'with-effort', model: 'opus', effort: 'xhigh' });
|
|
261
|
+
await savePersona({ ...base, name: 'inherit-effort', model: 'opus', effort: 'inherit' });
|
|
262
|
+
await savePersona({ ...base, name: 'unset-effort', model: 'opus' });
|
|
263
|
+
const raw = (n) => fsp.readFile(path.join(globalDir, `${n}.md`), 'utf8');
|
|
264
|
+
expect(await raw('with-effort')).toContain('effort: xhigh');
|
|
265
|
+
expect(await raw('inherit-effort')).not.toContain('effort:');
|
|
266
|
+
expect(await raw('unset-effort')).not.toContain('effort:');
|
|
267
|
+
|
|
268
|
+
const ps = await listPersonas({ globalDir, loadSessions: async () => ({ tabs: [] }), validatePath: identityValidatePath });
|
|
269
|
+
const by = Object.fromEntries(ps.map((p) => [p.name, p]));
|
|
270
|
+
expect(by['with-effort'].effort).toBe('xhigh');
|
|
271
|
+
expect(by['unset-effort'].effort).toBeNull();
|
|
272
|
+
|
|
273
|
+
// Untouched save of a no-effort persona (UI maps null -> 'inherit') must not add a line.
|
|
274
|
+
await savePersona({ ...base, name: 'unset-effort', model: 'opus', effort: by['unset-effort'].effort ?? 'inherit' });
|
|
275
|
+
expect(await raw('unset-effort')).not.toContain('effort:');
|
|
276
|
+
});
|
|
@@ -293,3 +293,19 @@ test('resolvePrdPersonaForSpawn caps the persona body at 6000 characters with a
|
|
|
293
293
|
expect(result.systemPrompt.toLowerCase()).toContain('truncat');
|
|
294
294
|
expect(result.systemPrompt).toContain('/home/user/.claude/agents/dev-lead.md');
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
// Epic-level override (New Session card) > persona frontmatter > fallback.
|
|
298
|
+
test('an Epic-level model override beats the persona model; an Epic without the field resolves unchanged', async () => {
|
|
299
|
+
const cwd = await mkTmpDir('sm-model-ovr-cwd-');
|
|
300
|
+
const globalDir = await mkTmpDir('sm-model-ovr-agents-');
|
|
301
|
+
writeIndex(cwd, {
|
|
302
|
+
withOverride: { id: 'withOverride', claudeSessionId: 's-ovr', agentType: 'p', model: 'claude-opus-5' },
|
|
303
|
+
legacy: { id: 'legacy', claudeSessionId: 's-old', agentType: 'p' },
|
|
304
|
+
inheritOverride: { id: 'inheritOverride', claudeSessionId: 's-inh', agentType: 'p', model: 'inherit' },
|
|
305
|
+
});
|
|
306
|
+
writePersona(globalDir, 'p', ['model: haiku']);
|
|
307
|
+
const deps = { globalDir, validatePath: (p) => p };
|
|
308
|
+
expect(resolveEpicModel({ cwd, claudeSessionId: 's-ovr', deps })).toBe('claude-opus-5');
|
|
309
|
+
expect(resolveEpicModel({ cwd, claudeSessionId: 's-old', deps })).toBe('haiku');
|
|
310
|
+
expect(resolveEpicModel({ cwd, claudeSessionId: 's-inh', deps })).toBe('haiku');
|
|
311
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentOverlayWrite.test.cjs — Agent Library's per-project OVERRIDE write path
|
|
3
|
+
* (savePersona with `projectName`): frontmatter-only overlay, removal on clear,
|
|
4
|
+
* full-body refusal, and end-to-end resolution for a scheduled PRD.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/agentOverlayWrite.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
import { test, expect, afterEach } from 'vitest';
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const fsp = require('node:fs/promises');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { savePersona, listPersonas, getPersonaBody } = require('../agentLibrary.cjs');
|
|
17
|
+
const { resolvePrdPersonaForSpawn } = require('../lib/agentModelResolve.cjs');
|
|
18
|
+
|
|
19
|
+
const tmpDirs = [];
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function setup() {
|
|
25
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-ov-cwd-'));
|
|
26
|
+
const globalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-ov-global-'));
|
|
27
|
+
tmpDirs.push(cwd, globalDir);
|
|
28
|
+
const globalFile = path.join(globalDir, 'dev-lead.md');
|
|
29
|
+
fs.writeFileSync(globalFile, '---\nname: dev-lead\ndescription: d\nmodel: sonnet\n---\nGlobal body.\n');
|
|
30
|
+
const overlayFile = path.join(cwd, '.claude', 'agents', 'dev-lead.md');
|
|
31
|
+
const deps = { globalDir, loadSessions: async () => ({ tabs: [{ cwd }] }), validatePath: (p) => p,
|
|
32
|
+
writeTextAtomic: async (p, t) => { await fsp.mkdir(path.dirname(p), { recursive: true }); await fsp.writeFile(p, t); } };
|
|
33
|
+
const projectName = path.basename(cwd);
|
|
34
|
+
const save = (over) => savePersona({ name: 'dev-lead', projectName, description: '', tools: [], color: '', tags: [], body: '', ...over, ...deps });
|
|
35
|
+
return { cwd, globalFile, overlayFile, deps, save, projectName };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('setting a model writes a frontmatter-only overlay with only that key; global untouched', async () => {
|
|
39
|
+
const { overlayFile, globalFile, save } = setup();
|
|
40
|
+
const before = fs.readFileSync(globalFile, 'utf8');
|
|
41
|
+
await save({ model: 'claude-opus-4-6', effort: 'inherit' });
|
|
42
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe('---\nmodel: claude-opus-4-6\n---\n');
|
|
43
|
+
expect(fs.readFileSync(globalFile, 'utf8')).toBe(before);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('adding effort adds only that key', async () => {
|
|
47
|
+
const { overlayFile, save } = setup();
|
|
48
|
+
await save({ model: 'claude-opus-4-6' });
|
|
49
|
+
await save({ model: 'claude-opus-4-6', effort: 'high' });
|
|
50
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe('---\nmodel: claude-opus-4-6\neffort: high\n---\n');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('clearing both removes the overlay file', async () => {
|
|
54
|
+
const { overlayFile, save } = setup();
|
|
55
|
+
await save({ model: 'claude-opus-4-6', effort: 'high' });
|
|
56
|
+
const r = await save({ model: 'inherit', effort: 'inherit' });
|
|
57
|
+
expect(r.removed).toBe(true);
|
|
58
|
+
expect(fs.existsSync(overlayFile)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a full-body overlay is refused, not rewritten', async () => {
|
|
62
|
+
const { overlayFile, save } = setup();
|
|
63
|
+
fs.mkdirSync(path.dirname(overlayFile), { recursive: true });
|
|
64
|
+
const full = '---\nname: dev-lead\nmodel: haiku\n---\nProject body.\n';
|
|
65
|
+
fs.writeFileSync(overlayFile, full);
|
|
66
|
+
await expect(save({ model: 'opus' })).rejects.toThrow(/full-body/);
|
|
67
|
+
expect(fs.readFileSync(overlayFile, 'utf8')).toBe(full);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('unknown project is rejected', async () => {
|
|
71
|
+
const { save } = setup();
|
|
72
|
+
await expect(save({ projectName: 'nope', model: 'opus' })).rejects.toThrow(/project not open/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('listPersonas counts a frontmatter-only overlay and reports global → project values', async () => {
|
|
76
|
+
const { save, deps, projectName } = setup();
|
|
77
|
+
await save({ model: 'claude-opus-4-6' });
|
|
78
|
+
const [p] = await listPersonas(deps);
|
|
79
|
+
expect(p.model).toBe('sonnet');
|
|
80
|
+
expect(p.overridingProjects).toEqual([projectName]);
|
|
81
|
+
expect(p.overrideDetails[0]).toMatchObject({ fields: ['model'], values: { model: 'claude-opus-4-6' }, bodyOverridden: false });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('scheduled-PRD model resolution returns the overlay model; global persona stays sonnet', async () => {
|
|
85
|
+
const { cwd, save, deps } = setup();
|
|
86
|
+
await save({ model: 'claude-opus-4-6' });
|
|
87
|
+
const r = await resolvePrdPersonaForSpawn({
|
|
88
|
+
cwd, agentType: 'dev-lead', fallbackModel: 'fallback',
|
|
89
|
+
deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) },
|
|
90
|
+
});
|
|
91
|
+
expect(r.model).toBe('claude-opus-4-6');
|
|
92
|
+
expect(r.systemPrompt).toContain('Global body.');
|
|
93
|
+
const [g] = await listPersonas(deps);
|
|
94
|
+
expect(g.model).toBe('sonnet');
|
|
95
|
+
});
|
|
@@ -206,3 +206,47 @@ test('non-zero exit with unrelated stderr keeps the generic fallback message', a
|
|
|
206
206
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
207
207
|
}
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// ---------- persona effort → --effort (agentEffortResolve.cjs) ----------
|
|
211
|
+
|
|
212
|
+
async function chatArgvForPersona(effortLine) {
|
|
213
|
+
attachRecorder();
|
|
214
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chat-effort-cwd-'));
|
|
215
|
+
const logFile = path.join(cwd, 'argv.log');
|
|
216
|
+
const sessionId = `effort-sess-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
217
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'agents'), { recursive: true });
|
|
218
|
+
fs.writeFileSync(
|
|
219
|
+
path.join(tmpHome, '.claude', 'agents', 'effort-persona.md'),
|
|
220
|
+
['---', 'name: effort-persona', 'model: opus', ...(effortLine ? [effortLine] : []), '---', 'body'].join('\n'),
|
|
221
|
+
);
|
|
222
|
+
const opsDir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
223
|
+
fs.mkdirSync(opsDir, { recursive: true });
|
|
224
|
+
fs.writeFileSync(path.join(opsDir, 'active-index.json'), JSON.stringify({
|
|
225
|
+
sessions: { e1: { id: 'e1', claudeSessionId: sessionId, agentType: 'effort-persona' } }, events: {},
|
|
226
|
+
}));
|
|
227
|
+
// First call succeeds outright: firstStderr unused because alwaysFail/n===1 → use a passing stub.
|
|
228
|
+
const stubPath = path.join(os.tmpdir(), `sm-effort-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
229
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\nrequire('fs').appendFileSync(${JSON.stringify(logFile)}, JSON.stringify(process.argv.slice(2)) + '\\n');\nprocess.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');\n`, { mode: 0o755 });
|
|
230
|
+
process.env.SM_CLAUDE_BIN = stubPath;
|
|
231
|
+
try {
|
|
232
|
+
curTab = 'tab-effort';
|
|
233
|
+
cr.run({ tabId: 'tab-effort', sessionId, prompt: 'hello', cwd, resume: false });
|
|
234
|
+
await waitFor(() => terminals().length >= 1 && fs.existsSync(logFile));
|
|
235
|
+
return spawns(logFile)[0];
|
|
236
|
+
} finally {
|
|
237
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
238
|
+
fs.rmSync(stubPath, { force: true });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('chat spawn: persona effort adds --effort <level> beside --model and --session-id', async () => {
|
|
243
|
+
const argv = await chatArgvForPersona('effort: xhigh');
|
|
244
|
+
expect(argv[argv.indexOf('--effort') + 1]).toBe('xhigh');
|
|
245
|
+
expect(argv[argv.indexOf('--model') + 1]).toBe('opus');
|
|
246
|
+
expect(argv).toContain('--session-id');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('chat spawn: effort inherit / absent adds no --effort token', async () => {
|
|
250
|
+
expect(await chatArgvForPersona('effort: inherit')).not.toContain('--effort');
|
|
251
|
+
expect(await chatArgvForPersona(null)).not.toContain('--effort');
|
|
252
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* personaMerge.test.cjs — overlay-over-global persona MERGE (lib/personaMerge.cjs)
|
|
3
|
+
* and every reader that goes through it.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/personaMerge.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, afterEach } from 'vitest';
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const fsp = require('node:fs/promises');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { resolveMergedPersona } = require('../lib/personaMerge.cjs');
|
|
16
|
+
const { readOverlayAwarePersonaModel, resolvePrdPersonaForSpawn } = require('../lib/agentModelResolve.cjs');
|
|
17
|
+
const { resolveEpicEffort } = require('../lib/agentEffortResolve.cjs');
|
|
18
|
+
const { resolveEffectiveModelInfo } = require('../lib/effectiveModelInfo.cjs');
|
|
19
|
+
const { getPersonaBody, listPersonas } = require('../agentLibrary.cjs');
|
|
20
|
+
|
|
21
|
+
const tmpDirs = [];
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mk(prefix) {
|
|
27
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
28
|
+
tmpDirs.push(d);
|
|
29
|
+
return d;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const noop = (p) => p;
|
|
33
|
+
|
|
34
|
+
function persona(dir, name, fmLines, body) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), ['---', ...fmLines, '---', body].join('\n'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setup({ global, overlay }) {
|
|
40
|
+
const cwd = mk('sm-pm-cwd-');
|
|
41
|
+
const globalDir = mk('sm-pm-global-');
|
|
42
|
+
if (global) persona(globalDir, 'dev-lead', global.fm, global.body);
|
|
43
|
+
if (overlay) persona(path.join(cwd, '.claude', 'agents'), 'dev-lead', overlay.fm, overlay.body);
|
|
44
|
+
return { cwd, globalDir, deps: { globalDir, validatePath: noop } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const GLOBAL = { fm: ['name: dev-lead', 'description: d', 'model: sonnet', 'effort: low'], body: 'Global body.' };
|
|
48
|
+
|
|
49
|
+
test('frontmatter-only overlay overrides model', () => {
|
|
50
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
51
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('claude-opus-4-6');
|
|
52
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
53
|
+
expect(m.provenance.model).toBe('overlay');
|
|
54
|
+
expect(m.provenance.description).toBe('global');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('frontmatter-only overlay overrides effort; source reports persona-overlay', () => {
|
|
58
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['effort: high'], body: '' } });
|
|
59
|
+
expect(resolveEpicEffort({ cwd, agentType: 'dev-lead', deps })).toEqual({ effort: 'high', source: 'persona-overlay' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a key absent from the overlay falls back to the global (model stays global, effort from overlay)', () => {
|
|
63
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['effort: high'], body: '' } });
|
|
64
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('sonnet');
|
|
65
|
+
// and the reverse: effort absent from overlay -> global effort, sourced 'persona'
|
|
66
|
+
const s2 = setup({ global: GLOBAL, overlay: { fm: ['model: opus'], body: '' } });
|
|
67
|
+
expect(resolveEpicEffort({ cwd: s2.cwd, agentType: 'dev-lead', deps: s2.deps })).toEqual({ effort: 'low', source: 'persona' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an overlay with no body uses the global body', () => {
|
|
71
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: opus'], body: '\n \n' } });
|
|
72
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
73
|
+
expect(m.body.trim()).toBe('Global body.');
|
|
74
|
+
expect(m.bodySource).toBe('global');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a full-body overlay replaces the body wholesale and keeps its raw text byte-identical', () => {
|
|
78
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['name: dev-lead', 'model: haiku'], body: 'Overlay body.' } });
|
|
79
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
80
|
+
expect(m.body.trim()).toBe('Overlay body.');
|
|
81
|
+
expect(m.bodySource).toBe('overlay');
|
|
82
|
+
expect(m.text).toBe(fs.readFileSync(path.join(cwd, '.claude', 'agents', 'dev-lead.md'), 'utf8'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("this repo's three full-body overlays resolve byte-identically (text + body + path)", async () => {
|
|
86
|
+
const repoAgents = path.resolve(__dirname, '..', '..', '..', '.claude', 'agents');
|
|
87
|
+
const globalDir = mk('sm-pm-global-');
|
|
88
|
+
for (const name of ['bilko-host-publisher', 'builder', 'project-home-builder']) {
|
|
89
|
+
persona(globalDir, name, [`name: ${name}`, 'description: g', 'model: opus'], 'global body');
|
|
90
|
+
const overlayFile = path.join(repoAgents, `${name}.md`);
|
|
91
|
+
const raw = fs.readFileSync(overlayFile, 'utf8');
|
|
92
|
+
const m = resolveMergedPersona(path.resolve(repoAgents, '..', '..'), name, { globalDir, validatePath: noop });
|
|
93
|
+
expect(m.text).toBe(raw);
|
|
94
|
+
expect(m.path).toBe(overlayFile);
|
|
95
|
+
expect(m.body).toBe(require('../lib/prdFrontmatter.cjs').splitFrontmatter(raw).body);
|
|
96
|
+
const viaLib = await getPersonaBody({ cwd: path.resolve(repoAgents, '..', '..'), name, globalDir, validatePath: noop });
|
|
97
|
+
expect(viaLib.text).toBe(raw);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('malformed overlay frontmatter degrades to the global persona, never throws, and is reported', () => {
|
|
102
|
+
const { cwd, globalDir } = setup({ global: GLOBAL });
|
|
103
|
+
fs.mkdirSync(path.join(cwd, '.claude', 'agents'), { recursive: true });
|
|
104
|
+
fs.writeFileSync(path.join(cwd, '.claude', 'agents', 'dev-lead.md'), '---\nmodel: claude-opus-4-6\nno closing fence');
|
|
105
|
+
const deps = { globalDir, validatePath: noop };
|
|
106
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
107
|
+
expect(m.fm.model).toBe('sonnet');
|
|
108
|
+
expect(m.fromOverlay).toBe(false);
|
|
109
|
+
expect(m.overlayIssue).toMatch(/malformed/);
|
|
110
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('sonnet');
|
|
111
|
+
const { todayFile } = require('../lib/opsErrorLog.cjs');
|
|
112
|
+
expect(fs.readFileSync(todayFile(cwd), 'utf8')).toMatch(/malformed frontmatter/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('an overlay with no global counterpart works standalone', () => {
|
|
116
|
+
const { cwd, deps } = setup({ overlay: { fm: ['name: dev-lead', 'model: opus'], body: 'Solo body.' } });
|
|
117
|
+
const m = resolveMergedPersona(cwd, 'dev-lead', deps);
|
|
118
|
+
expect(m.body.trim()).toBe('Solo body.');
|
|
119
|
+
expect(m.globalPath).toBeNull();
|
|
120
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('opus');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("overlay `model: inherit` defers to the settings default — it does NOT fall back to the global model", async () => {
|
|
124
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: inherit'], body: '' } });
|
|
125
|
+
expect(readOverlayAwarePersonaModel('dev-lead', { ...deps, cwd })).toBe('inherit');
|
|
126
|
+
const r = await resolvePrdPersonaForSpawn({ cwd, agentType: 'dev-lead', fallbackModel: 'sonnet-fallback', deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) } });
|
|
127
|
+
expect(r.model).toBe('sonnet-fallback');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('resolvePrdPersonaForSpawn hands the executor the merged model AND the global body for a frontmatter-only overlay', async () => {
|
|
131
|
+
const { cwd, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
132
|
+
const r = await resolvePrdPersonaForSpawn({ cwd, agentType: 'dev-lead', deps: { getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }) } });
|
|
133
|
+
expect(r.model).toBe('claude-opus-4-6');
|
|
134
|
+
expect(r.systemPrompt).toBe('Global body.');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a crafted agentType is rejected by every reader (PERSONA_NAME_RE traversal guard)', async () => {
|
|
138
|
+
const { cwd, deps } = setup({ global: GLOBAL });
|
|
139
|
+
const evil = '../../other-project/CLAUDE';
|
|
140
|
+
expect(resolveMergedPersona(cwd, evil, deps)).toBeNull();
|
|
141
|
+
expect(readOverlayAwarePersonaModel(evil, { ...deps, cwd })).toBeNull();
|
|
142
|
+
expect(await getPersonaBody({ cwd, name: evil, ...deps })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('getPersonaBody / effectiveModelInfo / listPersonas report per-field provenance, not a whole-file boolean', async () => {
|
|
146
|
+
const { cwd, globalDir, deps } = setup({ global: GLOBAL, overlay: { fm: ['model: claude-opus-4-6'], body: '' } });
|
|
147
|
+
const body = await getPersonaBody({ cwd, name: 'dev-lead', ...deps });
|
|
148
|
+
expect(body.provenance.model).toBe('overlay');
|
|
149
|
+
expect(body.provenance.effort).toBe('global');
|
|
150
|
+
|
|
151
|
+
const info = await resolveEffectiveModelInfo({
|
|
152
|
+
cwd,
|
|
153
|
+
agentType: 'dev-lead',
|
|
154
|
+
deps: {
|
|
155
|
+
...deps,
|
|
156
|
+
activeSessions: { projectRootOf: (p) => p },
|
|
157
|
+
getPersonaBody: (a) => getPersonaBody({ ...a, ...deps }),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
expect(info.modelAlias).toBe('claude-opus-4-6');
|
|
161
|
+
expect(info.modelSource).toBe('persona-overlay');
|
|
162
|
+
expect(info.personaProvenance.effort).toBe('global');
|
|
163
|
+
expect(info.personaEffortSource).toBe('persona');
|
|
164
|
+
|
|
165
|
+
const list = await listPersonas({ globalDir, validatePath: noop, loadSessions: async () => ({ tabs: [{ cwd }] }) });
|
|
166
|
+
const dev = list.find((p) => p.name === 'dev-lead');
|
|
167
|
+
expect(dev.overridingProjects).toEqual([path.basename(cwd)]);
|
|
168
|
+
expect(dev.overrideDetails[0]).toMatchObject({ fields: ['model'], bodyOverridden: false, issue: null });
|
|
169
|
+
});
|