document360-engine 0.2.8 → 0.2.10
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/index.js +1 -1
- package/package.json +1 -1
- package/skills/emit-screenshot-spec/SKILL.md +57 -34
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import{readdirSync as un,readFileSync as $t,existsSync as Ue,statSync as dn}from
|
|
|
7
7
|
`,"utf8"),!0}import{createHash as Er}from"node:crypto";function Dr(e){let t=e.replace(/^\uFEFF/,"").replace(/\r\n/g,`
|
|
8
8
|
`);return t=t.replace(/\n*$/,""),t===""?"":t+`
|
|
9
9
|
`}function H(e){return"sha256:"+Er("sha256").update(Dr(e),"utf8").digest("hex")}function Y(e,t){return H(`${e??""}
|
|
10
|
-
${t??""}`)}import{existsSync as Mr,readdirSync as Or,readFileSync as $r,statSync as Ur}from"node:fs";import{join as De}from"node:path";var bt={berlin:{apiUrl:"https://apihub.berlin.document360.net",portalUrl:"https://portal.berlin.document360.net",authorizationUrl:"https://identity.berlin.document360.net/connect/authorize",tokenUrl:"https://identity.berlin.document360.net/connect/token",clientId:"d360WriterAgentClient",scopes:["openid","profile","email","customerApi","offline_access"],acrValues:"project_select",redirectUri:"http://127.0.0.1:3223/callback",prompt:"login"},sharjah:{apiUrl:"https://apihub.sharjah.document360.net",portalUrl:"https://portal.sharjah.document360.net",authorizationUrl:"https://identity.sharjah.document360.net/connect/authorize",tokenUrl:"https://identity.sharjah.document360.net/connect/token",clientId:"d360WriterAgentClient",scopes:["openid","profile","email","customerApi","offline_access"],acrValues:"project_select",redirectUri:"http://127.0.0.1:3223/callback",prompt:"login"}};function Ar(){return Object.keys(bt)}function qo(e="berlin"){return pe({environment:e})}function pe(e){let t=e.environment??"berlin",r=bt[t];if(!r)throw new Error(`Unknown Document360 environment "${t}". Known: ${Ar().join(", ")}`);let n=process.env.D360_SCOPES?.split(/[\s,]+/).filter(Boolean);return{name:t,apiUrl:process.env.D360_API_URL??e.apiUrl??r.apiUrl,portalUrl:process.env.D360_PORTAL_URL??e.portalUrl??r.portalUrl,authorizationUrl:process.env.D360_AUTHORIZATION_URL??e.authorizationUrl??r.authorizationUrl,tokenUrl:process.env.D360_TOKEN_URL??e.tokenUrl??r.tokenUrl,clientId:process.env.D360_CLIENT_ID??e.clientId??r.clientId,scopes:n??e.scopes??r.scopes,acrValues:process.env.D360_ACR_VALUES??e.acrValues??r.acrValues,redirectUri:process.env.D360_REDIRECT_URI??e.redirectUri??r.redirectUri,prompt:process.env.D360_PROMPT??e.prompt??r.prompt}}function F(e,t){let r=M(e);if(r===null){let s=t??"berlin";return{name:s,connection:pe({environment:s}),project:{},production:!1}}let{name:n,profile:o}=Qe(r,t);return{name:n,connection:pe(o.connection),project:o.project??{},production:o.production===!0}}function zo(e,t,r){let n=M(e);n?.profiles?.[t]&&(n.profiles[t].project={...n.profiles[t].project,...r},Ze(n,e))}function _t(e,t){if(e?.docsDir)return e.docsDir.replace(/\\/g,"/").replace(/\/+$/,"");let r=new Set(Object.keys(t?.articles??{}).map(n=>n.split("/")[0]).filter(Boolean));if(r.size===1)return[...r][0];throw new Error(r.size===0?'Cannot locate the docs folder: no mapped articles yet. Set "docsDir" in .d360-writer.json.':`Cannot locate the docs folder: mapped articles span multiple roots (${[...r].join(", ")}). Set "docsDir" in .d360-writer.json.`)}var Ir=4;function vt(e,t,r,n={getAll:q,get:O}){let o=`/v3/projects/${t}`;return{async fetch(s){let[i,a]=await Promise.all([n.getAll(e,`${o}/workspaces/${r}/articles`),n.getAll(e,`${o}/workspaces/${r}/categories`)]),d=new Map;for(let y of i)d.set(y.id,{id:y.id,title:y.title,categoryId:y.category_id,hidden:y.hidden});let u=new Map;for(let y of a)u.set(y.id,{id:y.id,name:y.name,parentId:y.parent_category_id});let l=s.filter(y=>d.has(y)),f=0,p=async()=>{for(;f<l.length;){let y=l[f++],b=await n.get(e,`${o}/articles/${y}`,{query:{content_mode:"raw"}}),h=d.get(y);h.title=b.title??h.title,h.content=b.content,h.latestVersion=b.latest_version,h.modifiedAt=b.modified_at,h.contentHash=Y(b.title??h.title,b.content)}};return await Promise.all(Array.from({length:Math.min(Ir,l.length)},p)),{articles:d,categories:u}}}}function Nr(e){if(!e.mapped)return e.localExists?"untracked-local":"untracked-remote";if(!e.localExists&&!e.remoteExists)return"orphaned";if(!e.localExists)return"deleted-local";if(!e.remoteExists)return"deleted-remote";if(!e.hasBase)return"unknown-base";let t=e.localHashNow!==e.baseLocalHash,r=e.remoteContentHashNow!==e.baseRemoteContentHash;return t&&r?"conflict":t?"local-ahead":r?"remote-ahead":"in-sync"}function Lr(e,t){let r=De(e,t);if(!Mr(r))return[];let n=[],o=(s,i)=>{for(let a of Or(s)){let d=De(s,a),u=i?`${i}/${a}`:a;Ur(d).isDirectory()?o(d,u):a.endsWith(".md")&&n.push(u)}};return o(r,t.replace(/\\/g,"/")),n}function Br(e,t){let r=new Set(Object.values(e.categories)),n=new Map;for(let i of t.categories.values()){if(!i.parentId)continue;let a=n.get(i.parentId)??[];a.push(i.id),n.set(i.parentId,a)}let o=new Set,s=[...r];for(;s.length>0;){let i=s.shift();o.has(i)||(o.add(i),s.push(...n.get(i)??[]))}return o}async function St(e){let t=F(e.cwd,e.profileName),r=M(e.cwd),n=$(e.cwd,t.name);if(!n)throw new Error(`No d360-category-map.json section for profile "${t.name}". Publish at least one article first (/publish).`);let o={profile:t.name,connection:t.connection},s=n.projectId??t.project.projectId??V(o),i=n.workspaceId??t.project.workspaceId;if(!i)throw new Error(`No workspace recorded for profile "${t.name}". Run /workspace to select one.`);let a=_t(r,n),d=Object.values(n.articles).map(w=>w.id),l=await(e.provider??vt(o,s,i)).fetch(d),f=new Set(Lr(e.cwd,a)),p=[];for(let[w,R]of Object.entries(n.articles)){let z=f.has(w);f.delete(w);let C=l.articles.get(R.id),Q=!!(R.localHash&&R.remoteContentHash),j=Nr({mapped:!0,localExists:z,remoteExists:C!==void 0,hasBase:Q,localHashNow:z?H($r(De(e.cwd,w),"utf8")):void 0,baseLocalHash:R.localHash,remoteContentHashNow:C?.contentHash,baseRemoteContentHash:R.remoteContentHash}),U=[];if(j==="remote-ahead"||j==="conflict"){let J=C?.modifiedAt?.slice(0,10);U.push(`remote edited${J?` ${J}`:""}${C?.latestVersion?` (v${C.latestVersion})`:""}`)}C?.hidden&&U.push("hidden on D360"),p.push({path:w,articleId:R.id,title:C?.title,status:j,detail:U.length>0?U.join("; "):void 0})}for(let w of[...f].sort())p.push({path:w,articleId:null,status:"untracked-local"});let y=new Set(d),b=Br(n,l);for(let w of l.articles.values())y.has(w.id)||!w.categoryId||!b.has(w.categoryId)||p.push({path:null,articleId:w.id,title:w.title,status:"untracked-remote",detail:w.hidden?"hidden on D360":void 0});let h={};for(let w of p)h[w.status]=(h[w.status]??0)+1;return{profile:t.name,projectId:s,workspaceId:i,docsRoot:a,entries:p,counts:h,generatedAt:new Date().toISOString()}}import{posix as Ae}from"node:path";var qr=/\[([^\]]+)\]\(([^)\s]+)\)/g;function Ie(e,t,r){if(!r)return{content:e,resolved:0,unresolved:0};let n=Ae.dirname(t.replace(/\\/g,"/")),o=0,s=0;return{content:e.replace(qr,(a,d,u)=>{if(/^(https?:|mailto:|#|\/)/i.test(u)||!/\.(md|markdown)(#[^)]*)?$/i.test(u))return a;let l=u.indexOf("#"),f=l===-1?u:u.slice(0,l),p=l===-1?"":u.slice(l),y=Ae.normalize(Ae.join(n,f)).replace(/^\.\//,""),b=r.articles[y]?.url;return b?(o++,`[${d}](${b}${p})`):(s++,d)}),resolved:o,unresolved:s}}import{readdirSync as Fr}from"node:fs";import{join as fe}from"node:path";var Wr=new Set([".git","node_modules","dist","build","bin","obj","out","target",".vs",".vscode",".idea",".next","coverage",".turbo",".cache","packages"]),Hr=40,zr=3,Me=5e3,Jr=/\.(csproj|vbproj|fsproj)$|^(package\.json|go\.mod|Cargo\.toml|pyproject\.toml|setup\.py|pom\.xml|build\.gradle(\.kts)?)$/i,Gr=/\.sln$|^(Directory\.(Build|Packages)\.props|global\.json|go\.work)$/i;function Vr(e){return/\.(csproj|vbproj|fsproj|sln)$/i.test(e)||/^(Directory\.(Build|Packages)\.props|global\.json)$/i.test(e)?".NET":/^(package\.json|angular\.json|tsconfig\.json)$/i.test(e)?"JS/TS":/^(pyproject\.toml|requirements\.txt|setup\.py)$/i.test(e)?"Python":/^(go\.mod|go\.work)$/i.test(e)?"Go":/^Cargo\.toml$/i.test(e)?"Rust":/^(pom\.xml|build\.gradle(\.kts)?)$/i.test(e)?"Java":null}var Kr=/(?:^|[.\-_])(web|portal|ui|app|apps|client|site|widget|admin|dashboard|frontend|api)(?:$|[.\-_])/i,Yr=/(?:^|[.\-_])(tests?|specs?|helm|docker|migrations?|functions?|ci|builds?|scripts|tools|proxy|coverage|release|libs?|redis|signalr|websocket|dataaccess|nuget|sdk|infra|infrastructure|deploy|deployment|e2e)(?:$|[.\-_])/i;function Oe(e){let t=[],r=[];try{for(let n of Fr(e,{withFileTypes:!0}))n.isDirectory()?!n.name.startsWith(".")&&!Wr.has(n.name)&&t.push(n.name):n.isFile()&&r.push(n.name)}catch{}return{dirs:t,files:r}}function Qr(e){let t=new Set;for(let r of e){let n=Vr(r);n&&t.add(n)}return t}var xt=e=>e.some(t=>Jr.test(t)),Zr=e=>e.some(t=>Gr.test(t));function Xr(e){let t=0,r=n=>{if(t>=Me)return;let{dirs:o,files:s}=Oe(n);t+=s.length;for(let i of o){if(t>=Me)return;r(fe(n,i))}};return r(e),Math.min(t,Me)}function en(e,t){let r=e.split("/").pop()??e,n=t.size?[...t].join("+"):"";return Yr.test(r)?{recommended:!1,reason:`tests/infrastructure${n?` \xB7 ${n}`:""}`}:Kr.test(r)?{recommended:!0,reason:`user-facing surface${n?` \xB7 ${n}`:""}`}:{recommended:!1,reason:n?`${n} project`:"source folder"}}function Pt(e){let t=[],r=(s,i)=>{let a=Qr(i.files),{recommended:d,reason:u}=en(s,a);t.push({path:s,fileCount:Xr(fe(e,s)),stacks:[...a],recommended:d,reason:u})},n=(s,i)=>{let a=s?fe(e,s):e,d=Oe(a);if(s&&xt(d.files)){r(s,d);return}if(s&&d.dirs.length===0){r(s,d);return}let u=f=>xt(Oe(fe(a,f)).files);if((Zr(d.files)||d.dirs.some(u)||s==="")&&i<zr){for(let f of d.dirs)n(s?`${s}/${f}`:f,i+1);return}s&&r(s,d)};n("",0),t.sort((s,i)=>Number(i.recommended)-Number(s.recommended)||s.path.localeCompare(i.path));let o=t.filter(s=>s.recommended);return t.slice(0,Math.max(Hr,o.length))}var re={"claude-fable-5":{inputPerMTok:10,outputPerMTok:50},"claude-opus-4-8":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-7":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-6":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-5":{inputPerMTok:5,outputPerMTok:25},"claude-sonnet-4-6":{inputPerMTok:3,outputPerMTok:15},"claude-sonnet-4-5":{inputPerMTok:3,outputPerMTok:15},"claude-haiku-4-5":{inputPerMTok:1,outputPerMTok:5}},tn={opus:"claude-opus-4-8",sonnet:"claude-sonnet-4-6",haiku:"claude-haiku-4-5",fable:"claude-fable-5"},Tt="claude-opus-4-8";function Rt(e){let t=e&&(tn[e.toLowerCase()]??e);if(t&&re[t])return{model:t,rate:re[t],assumed:!1};if(t){let r=Object.keys(re).filter(n=>t.startsWith(n)).sort((n,o)=>o.length-n.length)[0];if(r)return{model:r,rate:re[r],assumed:!1}}return{model:Tt,rate:re[Tt],assumed:!0}}function ne(e,t){return e/1e6*t}var rn=3.5,nn={convert:{inFactor:1.3,outFactor:1.1,overheadInput:12e4,overheadOutput:24e3},rewrite:{inFactor:1.4,outFactor:1.2,overheadInput:13e4,overheadOutput:28e3},publish:{inFactor:1.1,outFactor:.2,overheadInput:5e4,overheadOutput:6e3},audit:{inFactor:1.2,outFactor:.15,overheadInput:1e5,overheadOutput:12e3}},Ct=.7,jt=1.6,me=e=>Math.round(e);function Et(e){let{op:t,model:r}=e,n=nn[t],{model:o,rate:s,assumed:i}=Rt(r),a=e.files.filter(h=>!(h.bytes>0)).map(h=>h.path),d=e.files.filter(h=>h.bytes>0),u=0,l=0;for(let h of d){let w=h.bytes/rn;u+=n.overheadInput+w*n.inFactor,l+=n.overheadOutput+w*n.outFactor}let f=[me(u*Ct),me(u*jt)],p=[me(l*Ct),me(l*jt)],y=[ne(f[0],s.inputPerMTok)+ne(p[0],s.outputPerMTok),ne(f[1],s.inputPerMTok)+ne(p[1],s.outputPerMTok)],b=`Rough estimate (per-article agent overhead + content), priced at ${o} list rates`+(i?" (model unset \u2014 assumed)":"")+". Actuals vary with cache reuse and reasoning depth; the live cost meter reconciles as the run proceeds.";return{model:o,modelAssumed:i,op:t,articles:d.length,skipped:a,inputTokens:f,outputTokens:p,usd:y,note:b}}var At="/v3/projects",P=e=>`${At}/${e}`;function x(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)??'{"success":true}'}]}}function v(e){return{content:[{type:"text",text:e instanceof Error?e.message:String(e)}],isError:!0}}function It(e,t){let r={profile:e.name,connection:e.connection},n=e.project.projectId,o=e.project.workspaceId,s=()=>V(r,n),i=c=>{let g=c??o;if(!g)throw new Error("No workspace_id given and none in the profile. Call d360_list_workspaces first.");return g},a=(c,g)=>{if(t.cwd)try{let k=$e(t.cwd,c);de(t.cwd,e.name,c.replace(/\\/g,"/"),{...g,...on(k)?{localHash:H(Dt(k,"utf8"))}:{}})}catch{}},d=()=>t.writesAllowed?null:v(`Refusing to write to PRODUCTION profile "${e.name}". Authorize this session first: run /allow-prod in the REPL, or pass --yes in one-shot/CI.`),u=S("d360_context","Report the active Document360 connection: profile name, environment, project/workspace id, and whether it is a production profile. Call this before maintaining d360-category-map.json so you scope IDs to the right profile.",{},async()=>{try{return x({profile:e.name,environment:e.connection.name,production:e.production,projectId:n??V(r),workspaceId:o??null})}catch(c){return v(c)}}),l=S("d360_list_projects","List all Document360 projects the signed-in user can access (id, name, sub-domain, status).",{},async()=>{try{return x(await q(r,At))}catch(c){return v(c)}}),f=S("d360_list_workspaces","List workspaces (project versions) for the current project. Each has id, name, slug, is_default, workspace_type.",{project_id:m.string().optional().describe("Defaults to the logged-in/config project.")},async c=>{try{return x(await q(r,`${P(c.project_id??s())}/workspaces`))}catch(g){return v(g)}}),p=S("d360_list_categories","List categories in a workspace (the folder structure for articles).",{workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await q(r,`${P(c.project_id??s())}/workspaces/${g}/categories`)):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),y=S("d360_list_articles","List articles in a workspace (id, title, status, category). Use to see existing docs before writing.",{workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await q(r,`${P(c.project_id??s())}/workspaces/${g}/articles`)):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),b=S("d360_get_article","Get a single article including its content. content_mode=raw returns the stored markdown source (best for editing); display returns processed content.",{article_id:m.string(),content_mode:m.enum(["raw","display"]).optional().describe("raw = stored markdown source (default), display = processed."),published:m.boolean().optional().describe("Read the published version instead of the latest draft."),project_id:m.string().optional()},async c=>{try{return x(await O(r,`${P(c.project_id??s())}/articles/${c.article_id}`,{query:{content_mode:c.content_mode,published:c.published}}))}catch(g){return v(g)}}),h=S("d360_ai_query","Ask Document360's AI search over a workspace's published content. Returns an answer grounded in existing articles \u2014 use to check what's already documented.",{query:m.string(),workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await K(r,`${P(c.project_id??s())}/workspaces/ai/query`,{body:{query:c.query,workspace_id:g}})):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),w=S("d360_sync_status","Deterministic drift report: local docs tree vs Document360, classified against the sync bases in d360-category-map.json. Call this BEFORE any docs gap/coverage analysis \u2014 it proves whether the local docs tree can be trusted as the Document360 inventory (no need to fetch article content). Statuses: local-ahead (push pending), remote-ahead (portal edit to pull), conflict, untracked-local/remote, deleted-local/remote, unknown-base (no base recorded yet).",{},async()=>{try{if(!t.cwd)return v("Sync status needs a repo context (no cwd configured for this session).");let c=await St({cwd:t.cwd,profileName:e.name});return x({profile:c.profile,docsRoot:c.docsRoot,generatedAt:c.generatedAt,counts:c.counts,attention:c.entries.filter(g=>g.status!=="in-sync")})}catch(c){return v(c)}}),R=S("d360_repo_inventory","Deterministic inventory of candidate source folders for documentation scope (no file reads of your own needed). Returns project-level folders with: path, fileCount, detected stacks, a `recommended` flag (user-facing surfaces pre-recommended; tests/infrastructure not), and a one-line reason. Use this when analyzing a large/multi-project repo to decide which folders back the docs \u2014 it is the same data the /scope picker pre-ticks, so your recommendation and the picker agree. Cheap; safe to call before reading any source.",{},async()=>{try{return t.cwd?x(Pt(t.cwd)):v("Repo inventory needs a repo context (no cwd configured for this session).")}catch(c){return v(c)}}),z=S("d360_estimate_cost",'Deterministic, no-network token/cost ESTIMATE for a bulk operation. Call this BEFORE you propose a repo-scale run (convert / rewrite / publish / audit MANY articles) so you can show the user the article count and a cost band and get an explicit go-ahead \u2014 see the "Confirm before bulk or irreversible actions" rule. It sizes work from local file BYTES (coarse \u2014 returns [low, high] bands, not a quote). Omit `paths` to estimate over every article tracked in d360-category-map.json; pass `paths` for a subset. Cheap and read-only; safe to call before any write.',{op:m.enum(["convert","rewrite","publish","audit"]).describe("What you intend to do to each article (drives the output-size heuristic)."),paths:m.array(m.string()).optional().describe("Repo-relative .md paths. Omit or leave empty to estimate over all articles in the category map.")},async({op:c,paths:g})=>{try{if(!t.cwd)return v("Cost estimate needs a repo context (no cwd configured for this session).");let k;if(g&&g.length>0)k=g.map(_=>_.replace(/\\/g,"/"));else{let _=$(t.cwd,e.name);if(k=_?Object.keys(_.articles):[],k.length===0)return v("No articles are tracked in d360-category-map.json for this profile, and no `paths` were given. Pass `paths` explicitly (the repo-relative .md files you intend to operate on).")}let E=k.map(_=>{let D=0;try{D=sn($e(t.cwd,_)).size}catch{D=0}return{path:_,bytes:D}});return x(Et({files:E,op:c,model:t.model}))}catch(k){return v(k)}}),C=S("d360_create_category","Create a category (a docs folder). Returns the new category id.",{name:m.string(),workspace_id:m.string().optional(),parent_category_id:m.string().optional().describe("Omit for a top-level category."),content:m.string().optional(),slug:m.string().optional(),order:m.number().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/categories`,{body:{name:c.name,workspace_id:i(c.workspace_id),parent_category_id:c.parent_category_id,content:c.content,slug:c.slug,order:c.order,content_type:"markdown"}}))}catch(k){return v(k)}}),Q=S("d360_create_article","Create a DRAFT article in a category. The body is always Markdown (product rule \u2014 we never create WYSIWYG/Block articles). Returns the new article id. Does not publish.",{title:m.string(),category_id:m.string(),content:m.string().optional().describe("Markdown body."),workspace_id:m.string().optional(),slug:m.string().optional(),order:m.number().optional(),project_id:m.string().optional(),local_path:m.string().optional().describe("Repo-relative path of the local .md this article mirrors (e.g. user-docs/01-intro/01-a.md). When given, the new article id + sync base are recorded into d360-category-map.json automatically \u2014 do not edit the articles map yourself.")},async c=>{let g=d();if(g)return g;try{let k=c.project_id??s(),E=c.content;typeof E=="string"&&c.local_path&&t.cwd&&(E=Ie(E,c.local_path,$(t.cwd,e.name)).content);let _=await K(r,`${P(k)}/articles/bulk`,{body:{articles:[{title:c.title,category_id:c.category_id,workspace_id:i(c.workspace_id),content:E,slug:c.slug,order:c.order,content_type:"markdown"}]}}),D=Array.isArray(_)?_[0]:void 0;if(c.local_path&&typeof D?.id=="string"){let I;try{let W=await O(r,`${P(k)}/articles/${D.id}`,{});I=typeof W?.url=="string"?W.url:void 0}catch{}a(c.local_path,{id:D.id,remoteContentHash:Y(c.title,E),remoteVersion:1,...I?{url:I}:{}})}return x(_)}catch(k){return v(k)}}),j=S("d360_update_article","Update an article's title/content/category. Edits the latest draft by default; set auto_fork to safely edit a published article (creates a new draft version).",{article_id:m.string(),title:m.string().optional(),content:m.string().optional().describe("Markdown body."),category_id:m.string().optional(),hidden:m.boolean().optional(),version_number:m.number().optional(),auto_fork:m.boolean().optional().describe("If the target version is published, fork a new draft instead of erroring."),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{let{article_id:k,project_id:E,..._}=c,D=E??s(),I=typeof _.content=="string"&&t.cwd?$(t.cwd,e.name):null,W=I?kt(I,k):null;typeof _.content=="string"&&W&&I&&(_.content=Ie(_.content,W,I).content);let G=null;if(typeof _.content=="string"&&t.onUiEvent)try{G=await O(r,`${P(D)}/articles/${k}`,{query:{content_mode:"raw"}})}catch{}let Be=e.project.languageCode??G?.lang_code,qe=await mt(r,`${P(D)}/articles/${k}`,{body:_,...Be?{query:{lang_code:Be}}:{}});if(G&&typeof _.content=="string"&&(G.content??"")!==_.content&&t.onUiEvent?.({type:"article_diff",articleId:k,title:G.title??null,oldContent:G.content??"",newContent:_.content}),typeof _.content=="string"&&W)try{let X=qe,Fe=_.title??X?.title??G?.title;typeof Fe=="string"&&a(W,{id:k,remoteContentHash:Y(Fe,_.content),...typeof X?.latest_version=="number"?{remoteVersion:X.latest_version}:{},...typeof X?.modified_at=="string"?{remoteModifiedAt:X.modified_at}:{}})}catch{}return x(qe)}catch(k){return v(k)}}),U=S("d360_fork_article","Fork a published article into a new draft version \u2014 the safe way to start editing live content.",{article_id:m.string(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/fork`))}catch(k){return v(k)}}),J=S("d360_publish_article","Publish an article version. This makes the draft live to readers \u2014 only call when the user explicitly asks to publish.",{article_id:m.string(),version_number:m.number(),workspace_id:m.string().optional(),message:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/publish`,{body:{workspace_id:i(c.workspace_id),version_number:c.version_number,message:c.message}}))}catch(k){return v(k)}}),T=S("d360_unpublish_article","Unpublish an article version, reverting it to draft (removes it from readers).",{article_id:m.string(),version_number:m.number().describe("The published version to unpublish (see d360_get_article)."),workspace_id:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/unpublish`,{body:{workspace_id:i(c.workspace_id),version_number:c.version_number}}))}catch(k){return v(k)}}),Ht=S("d360_upload_drive_file","Upload a local file (e.g. a captured screenshot PNG) to Drive and return its URL for embedding in an article. Uploads to the default folder unless folder_id is given.",{file_path:m.string().describe("Local path to the file (absolute, or relative to the repo)."),folder_id:m.string().optional(),title:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{let k=c.project_id??s(),E=cn(c.file_path)?c.file_path:$e(process.cwd(),c.file_path),_=c.folder_id;if(!_&&(_=(await O(r,`${P(k)}/drive/folders/default`))?.id,!_))return v("Could not resolve the default Drive folder.");let D=Dt(E),I=new FormData;return I.append("file",new Blob([D]),c.title??an(E)),x(await gt(r,`${P(k)}/drive/folders/${_}/files`,I))}catch(k){return v(k)}}),zt=S("d360_search_drive","Search Drive files (e.g. to reuse an already-uploaded screenshot instead of uploading a duplicate).",{search_keyword:m.string().optional(),allow_images_only:m.boolean().optional(),project_id:m.string().optional()},async c=>{try{return x(await q(r,`${P(c.project_id??s())}/drive/search`,{query:{search_keyword:c.search_keyword,allow_images_only:c.allow_images_only}}))}catch(g){return v(g)}});return ln({name:"document360",version:"0.2.0",instructions:"First-party Document360 tools. The signed-in user's permissions apply server-side. Project/workspace default from the active profile; pass ids explicitly to override. Create articles as DRAFTS; only call d360_publish_article when the user explicitly asks to publish. All articles are Markdown \u2014 the tools enforce this; never attempt WYSIWYG/Block content. To edit a published article, fork it (d360_fork_article) or update with auto_fork. Before any docs gap/coverage analysis, call d360_sync_status to verify the local docs tree matches Document360. When scoping a large repo (which folders back the docs), call d360_repo_inventory for the candidate folders. Before a bulk run (converting/publishing/auditing many articles), call d360_estimate_cost and show the user the count + cost band first. Auth errors mean the session expired \u2014 tell the user to run /login (works inside this session).",tools:[u,l,f,p,y,b,h,w,R,z,C,Q,j,U,J,T,Ht,zt]})}var Ut="CLAUDE.md";function Mt(e){if(!Ue(e))return[];let t=[];for(let r of un(e)){let n=Ne(e,r);if(r===Ut||!dn(n).isDirectory())continue;let o=Ne(n,"SKILL.md");Ue(o)&&t.push({name:r,body:$t(o,"utf8")})}return t}function Ot(e){let t=Ne(e,Ut);return Ue(t)?$t(t,"utf8"):null}function fn(e,t){let r=Ke(),n=Ye(e),o=Ot(r),s=Ot(n),i=Mt(r),a=Mt(n),d=new Set(a.map(f=>f.name)),u=[...i.filter(f=>!d.has(f.name)),...a],l=[];if(o&&l.push(o),s&&l.push(`# Project addendum
|
|
10
|
+
${t??""}`)}import{existsSync as Mr,readdirSync as Or,readFileSync as $r,statSync as Ur}from"node:fs";import{join as De}from"node:path";var bt={berlin:{apiUrl:"https://apihub.berlin.document360.net",portalUrl:"https://portal.berlin.document360.net",authorizationUrl:"https://identity.berlin.document360.net/connect/authorize",tokenUrl:"https://identity.berlin.document360.net/connect/token",clientId:"d360WriterAgentClient",scopes:["openid","profile","email","customerApi","offline_access"],acrValues:"project_select",redirectUri:"http://127.0.0.1:3223/callback",prompt:"login"},sharjah:{apiUrl:"https://apihub.sharjah.document360.net",portalUrl:"https://portal.sharjah.document360.net",authorizationUrl:"https://identity.sharjah.document360.net/connect/authorize",tokenUrl:"https://identity.sharjah.document360.net/connect/token",clientId:"d360WriterAgentClient",scopes:["openid","profile","email","customerApi","offline_access"],acrValues:"project_select",redirectUri:"http://127.0.0.1:3223/callback",prompt:"login"}};function Ar(){return Object.keys(bt)}function qo(e="berlin"){return pe({environment:e})}function pe(e){let t=e.environment??"berlin",r=bt[t];if(!r)throw new Error(`Unknown Document360 environment "${t}". Known: ${Ar().join(", ")}`);let n=process.env.D360_SCOPES?.split(/[\s,]+/).filter(Boolean);return{name:t,apiUrl:process.env.D360_API_URL??e.apiUrl??r.apiUrl,portalUrl:process.env.D360_PORTAL_URL??e.portalUrl??r.portalUrl,authorizationUrl:process.env.D360_AUTHORIZATION_URL??e.authorizationUrl??r.authorizationUrl,tokenUrl:process.env.D360_TOKEN_URL??e.tokenUrl??r.tokenUrl,clientId:process.env.D360_CLIENT_ID??e.clientId??r.clientId,scopes:n??e.scopes??r.scopes,acrValues:process.env.D360_ACR_VALUES??e.acrValues??r.acrValues,redirectUri:process.env.D360_REDIRECT_URI??e.redirectUri??r.redirectUri,prompt:process.env.D360_PROMPT??e.prompt??r.prompt}}function F(e,t){let r=M(e);if(r===null){let s=t??"berlin";return{name:s,connection:pe({environment:s}),project:{},production:!1}}let{name:n,profile:o}=Qe(r,t);return{name:n,connection:pe(o.connection),project:o.project??{},production:o.production===!0}}function zo(e,t,r){let n=M(e);n?.profiles?.[t]&&(n.profiles[t].project={...n.profiles[t].project,...r},Ze(n,e))}function _t(e,t){if(e?.docsDir)return e.docsDir.replace(/\\/g,"/").replace(/\/+$/,"");let r=new Set(Object.keys(t?.articles??{}).map(n=>n.split("/")[0]).filter(Boolean));if(r.size===1)return[...r][0];throw new Error(r.size===0?'Cannot locate the docs folder: no mapped articles yet. Set "docsDir" in .d360-writer.json.':`Cannot locate the docs folder: mapped articles span multiple roots (${[...r].join(", ")}). Set "docsDir" in .d360-writer.json.`)}var Ir=4;function vt(e,t,r,n={getAll:q,get:O}){let o=`/v3/projects/${t}`;return{async fetch(s){let[i,a]=await Promise.all([n.getAll(e,`${o}/workspaces/${r}/articles`),n.getAll(e,`${o}/workspaces/${r}/categories`)]),d=new Map;for(let y of i)d.set(y.id,{id:y.id,title:y.title,categoryId:y.category_id,hidden:y.hidden});let u=new Map;for(let y of a)u.set(y.id,{id:y.id,name:y.name,parentId:y.parent_category_id});let l=s.filter(y=>d.has(y)),f=0,p=async()=>{for(;f<l.length;){let y=l[f++],b=await n.get(e,`${o}/articles/${y}`,{query:{content_mode:"raw"}}),h=d.get(y);h.title=b.title??h.title,h.content=b.content,h.latestVersion=b.latest_version,h.modifiedAt=b.modified_at,h.contentHash=Y(b.title??h.title,b.content)}};return await Promise.all(Array.from({length:Math.min(Ir,l.length)},p)),{articles:d,categories:u}}}}function Nr(e){if(!e.mapped)return e.localExists?"untracked-local":"untracked-remote";if(!e.localExists&&!e.remoteExists)return"orphaned";if(!e.localExists)return"deleted-local";if(!e.remoteExists)return"deleted-remote";if(!e.hasBase)return"unknown-base";let t=e.localHashNow!==e.baseLocalHash,r=e.remoteContentHashNow!==e.baseRemoteContentHash;return t&&r?"conflict":t?"local-ahead":r?"remote-ahead":"in-sync"}function Lr(e,t){let r=De(e,t);if(!Mr(r))return[];let n=[],o=(s,i)=>{for(let a of Or(s)){let d=De(s,a),u=i?`${i}/${a}`:a;Ur(d).isDirectory()?o(d,u):a.endsWith(".md")&&n.push(u)}};return o(r,t.replace(/\\/g,"/")),n}function Br(e,t){let r=new Set(Object.values(e.categories)),n=new Map;for(let i of t.categories.values()){if(!i.parentId)continue;let a=n.get(i.parentId)??[];a.push(i.id),n.set(i.parentId,a)}let o=new Set,s=[...r];for(;s.length>0;){let i=s.shift();o.has(i)||(o.add(i),s.push(...n.get(i)??[]))}return o}async function St(e){let t=F(e.cwd,e.profileName),r=M(e.cwd),n=$(e.cwd,t.name);if(!n)throw new Error(`No d360-category-map.json section for profile "${t.name}". Publish at least one article first (/publish).`);let o={profile:t.name,connection:t.connection},s=n.projectId??t.project.projectId??V(o),i=n.workspaceId??t.project.workspaceId;if(!i)throw new Error(`No workspace recorded for profile "${t.name}". Run /workspace to select one.`);let a=_t(r,n),d=Object.values(n.articles).map(w=>w.id),l=await(e.provider??vt(o,s,i)).fetch(d),f=new Set(Lr(e.cwd,a)),p=[];for(let[w,R]of Object.entries(n.articles)){let z=f.has(w);f.delete(w);let C=l.articles.get(R.id),Q=!!(R.localHash&&R.remoteContentHash),j=Nr({mapped:!0,localExists:z,remoteExists:C!==void 0,hasBase:Q,localHashNow:z?H($r(De(e.cwd,w),"utf8")):void 0,baseLocalHash:R.localHash,remoteContentHashNow:C?.contentHash,baseRemoteContentHash:R.remoteContentHash}),U=[];if(j==="remote-ahead"||j==="conflict"){let J=C?.modifiedAt?.slice(0,10);U.push(`remote edited${J?` ${J}`:""}${C?.latestVersion?` (v${C.latestVersion})`:""}`)}C?.hidden&&U.push("hidden on D360"),p.push({path:w,articleId:R.id,title:C?.title,status:j,detail:U.length>0?U.join("; "):void 0})}for(let w of[...f].sort())p.push({path:w,articleId:null,status:"untracked-local"});let y=new Set(d),b=Br(n,l);for(let w of l.articles.values())y.has(w.id)||!w.categoryId||!b.has(w.categoryId)||p.push({path:null,articleId:w.id,title:w.title,status:"untracked-remote",detail:w.hidden?"hidden on D360":void 0});let h={};for(let w of p)h[w.status]=(h[w.status]??0)+1;return{profile:t.name,projectId:s,workspaceId:i,docsRoot:a,entries:p,counts:h,generatedAt:new Date().toISOString()}}import{posix as Ae}from"node:path";var qr=/\[([^\]]+)\]\(([^)\s]+)\)/g;function Ie(e,t,r){if(!r)return{content:e,resolved:0,unresolved:0};let n=Ae.dirname(t.replace(/\\/g,"/")),o=0,s=0;return{content:e.replace(qr,(a,d,u)=>{if(/^(https?:|mailto:|#|\/)/i.test(u)||!/\.(md|markdown)(#[^)]*)?$/i.test(u))return a;let l=u.indexOf("#"),f=l===-1?u:u.slice(0,l),p=l===-1?"":u.slice(l),y=Ae.normalize(Ae.join(n,f)).replace(/^\.\//,""),b=r.articles[y]?.url;return b?(o++,`[${d}](${b}${p})`):(s++,d)}),resolved:o,unresolved:s}}import{readdirSync as Fr}from"node:fs";import{join as fe}from"node:path";var Wr=new Set([".git","node_modules","dist","build","bin","obj","out","target",".vs",".vscode",".idea",".next","coverage",".turbo",".cache","packages"]),Hr=40,zr=3,Me=5e3,Jr=/\.(csproj|vbproj|fsproj)$|^(package\.json|go\.mod|Cargo\.toml|pyproject\.toml|setup\.py|pom\.xml|build\.gradle(\.kts)?)$/i,Gr=/\.sln$|^(Directory\.(Build|Packages)\.props|global\.json|go\.work)$/i;function Vr(e){return/\.(csproj|vbproj|fsproj|sln)$/i.test(e)||/^(Directory\.(Build|Packages)\.props|global\.json)$/i.test(e)?".NET":/^(package\.json|angular\.json|tsconfig\.json)$/i.test(e)?"JS/TS":/^(pyproject\.toml|requirements\.txt|setup\.py)$/i.test(e)?"Python":/^(go\.mod|go\.work)$/i.test(e)?"Go":/^Cargo\.toml$/i.test(e)?"Rust":/^(pom\.xml|build\.gradle(\.kts)?)$/i.test(e)?"Java":null}var Kr=/(?:^|[.\-_])(web|portal|ui|app|apps|client|site|widget|admin|dashboard|frontend|api)(?:$|[.\-_])/i,Yr=/(?:^|[.\-_])(tests?|specs?|helm|docker|migrations?|functions?|ci|builds?|scripts|tools|proxy|coverage|release|libs?|redis|signalr|websocket|dataaccess|nuget|sdk|infra|infrastructure|deploy|deployment|e2e)(?:$|[.\-_])/i;function Oe(e){let t=[],r=[];try{for(let n of Fr(e,{withFileTypes:!0}))n.isDirectory()?!n.name.startsWith(".")&&!Wr.has(n.name)&&t.push(n.name):n.isFile()&&r.push(n.name)}catch{}return{dirs:t,files:r}}function Qr(e){let t=new Set;for(let r of e){let n=Vr(r);n&&t.add(n)}return t}var xt=e=>e.some(t=>Jr.test(t)),Zr=e=>e.some(t=>Gr.test(t));function Xr(e){let t=0,r=n=>{if(t>=Me)return;let{dirs:o,files:s}=Oe(n);t+=s.length;for(let i of o){if(t>=Me)return;r(fe(n,i))}};return r(e),Math.min(t,Me)}function en(e,t){let r=e.split("/").pop()??e,n=t.size?[...t].join("+"):"";return Yr.test(r)?{recommended:!1,reason:`tests/infrastructure${n?` \xB7 ${n}`:""}`}:Kr.test(r)?{recommended:!0,reason:`user-facing surface${n?` \xB7 ${n}`:""}`}:{recommended:!1,reason:n?`${n} project`:"source folder"}}function Pt(e){let t=[],r=(s,i)=>{let a=Qr(i.files),{recommended:d,reason:u}=en(s,a);t.push({path:s,fileCount:Xr(fe(e,s)),stacks:[...a],recommended:d,reason:u})},n=(s,i)=>{let a=s?fe(e,s):e,d=Oe(a);if(s&&xt(d.files)){r(s,d);return}if(s&&d.dirs.length===0){r(s,d);return}let u=f=>xt(Oe(fe(a,f)).files);if((Zr(d.files)||d.dirs.some(u)||s==="")&&i<zr){for(let f of d.dirs)n(s?`${s}/${f}`:f,i+1);return}s&&r(s,d)};n("",0),t.sort((s,i)=>Number(i.recommended)-Number(s.recommended)||s.path.localeCompare(i.path));let o=t.filter(s=>s.recommended);return t.slice(0,Math.max(Hr,o.length))}var re={"claude-fable-5":{inputPerMTok:10,outputPerMTok:50},"claude-opus-4-8":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-7":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-6":{inputPerMTok:5,outputPerMTok:25},"claude-opus-4-5":{inputPerMTok:5,outputPerMTok:25},"claude-sonnet-4-6":{inputPerMTok:3,outputPerMTok:15},"claude-sonnet-4-5":{inputPerMTok:3,outputPerMTok:15},"claude-haiku-4-5":{inputPerMTok:1,outputPerMTok:5}},tn={opus:"claude-opus-4-8",sonnet:"claude-sonnet-4-6",haiku:"claude-haiku-4-5",fable:"claude-fable-5"},Tt="claude-opus-4-8";function Rt(e){let t=e&&(tn[e.toLowerCase()]??e);if(t&&re[t])return{model:t,rate:re[t],assumed:!1};if(t){let r=Object.keys(re).filter(n=>t.startsWith(n)).sort((n,o)=>o.length-n.length)[0];if(r)return{model:r,rate:re[r],assumed:!1}}return{model:Tt,rate:re[Tt],assumed:!0}}function ne(e,t){return e/1e6*t}var rn=3.5,nn={convert:{inFactor:1.3,outFactor:1.1,overheadInput:85e3,overheadOutput:16e3},rewrite:{inFactor:1.4,outFactor:1.2,overheadInput:95e3,overheadOutput:19e3},publish:{inFactor:1.1,outFactor:.2,overheadInput:35e3,overheadOutput:4e3},audit:{inFactor:1.2,outFactor:.15,overheadInput:7e4,overheadOutput:9e3}},Ct=.7,jt=1.6,me=e=>Math.round(e);function Et(e){let{op:t,model:r}=e,n=nn[t],{model:o,rate:s,assumed:i}=Rt(r),a=e.files.filter(h=>!(h.bytes>0)).map(h=>h.path),d=e.files.filter(h=>h.bytes>0),u=0,l=0;for(let h of d){let w=h.bytes/rn;u+=n.overheadInput+w*n.inFactor,l+=n.overheadOutput+w*n.outFactor}let f=[me(u*Ct),me(u*jt)],p=[me(l*Ct),me(l*jt)],y=[ne(f[0],s.inputPerMTok)+ne(p[0],s.outputPerMTok),ne(f[1],s.inputPerMTok)+ne(p[1],s.outputPerMTok)],b=`Rough estimate (per-article agent overhead + content), priced at ${o} list rates`+(i?" (model unset \u2014 assumed)":"")+". Actuals vary with cache reuse and reasoning depth; the live cost meter reconciles as the run proceeds.";return{model:o,modelAssumed:i,op:t,articles:d.length,skipped:a,inputTokens:f,outputTokens:p,usd:y,note:b}}var At="/v3/projects",P=e=>`${At}/${e}`;function x(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)??'{"success":true}'}]}}function v(e){return{content:[{type:"text",text:e instanceof Error?e.message:String(e)}],isError:!0}}function It(e,t){let r={profile:e.name,connection:e.connection},n=e.project.projectId,o=e.project.workspaceId,s=()=>V(r,n),i=c=>{let g=c??o;if(!g)throw new Error("No workspace_id given and none in the profile. Call d360_list_workspaces first.");return g},a=(c,g)=>{if(t.cwd)try{let k=$e(t.cwd,c);de(t.cwd,e.name,c.replace(/\\/g,"/"),{...g,...on(k)?{localHash:H(Dt(k,"utf8"))}:{}})}catch{}},d=()=>t.writesAllowed?null:v(`Refusing to write to PRODUCTION profile "${e.name}". Authorize this session first: run /allow-prod in the REPL, or pass --yes in one-shot/CI.`),u=S("d360_context","Report the active Document360 connection: profile name, environment, project/workspace id, and whether it is a production profile. Call this before maintaining d360-category-map.json so you scope IDs to the right profile.",{},async()=>{try{return x({profile:e.name,environment:e.connection.name,production:e.production,projectId:n??V(r),workspaceId:o??null})}catch(c){return v(c)}}),l=S("d360_list_projects","List all Document360 projects the signed-in user can access (id, name, sub-domain, status).",{},async()=>{try{return x(await q(r,At))}catch(c){return v(c)}}),f=S("d360_list_workspaces","List workspaces (project versions) for the current project. Each has id, name, slug, is_default, workspace_type.",{project_id:m.string().optional().describe("Defaults to the logged-in/config project.")},async c=>{try{return x(await q(r,`${P(c.project_id??s())}/workspaces`))}catch(g){return v(g)}}),p=S("d360_list_categories","List categories in a workspace (the folder structure for articles).",{workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await q(r,`${P(c.project_id??s())}/workspaces/${g}/categories`)):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),y=S("d360_list_articles","List articles in a workspace (id, title, status, category). Use to see existing docs before writing.",{workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await q(r,`${P(c.project_id??s())}/workspaces/${g}/articles`)):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),b=S("d360_get_article","Get a single article including its content. content_mode=raw returns the stored markdown source (best for editing); display returns processed content.",{article_id:m.string(),content_mode:m.enum(["raw","display"]).optional().describe("raw = stored markdown source (default), display = processed."),published:m.boolean().optional().describe("Read the published version instead of the latest draft."),project_id:m.string().optional()},async c=>{try{return x(await O(r,`${P(c.project_id??s())}/articles/${c.article_id}`,{query:{content_mode:c.content_mode,published:c.published}}))}catch(g){return v(g)}}),h=S("d360_ai_query","Ask Document360's AI search over a workspace's published content. Returns an answer grounded in existing articles \u2014 use to check what's already documented.",{query:m.string(),workspace_id:m.string().optional().describe("Defaults to d360.workspaceId in config."),project_id:m.string().optional()},async c=>{try{let g=c.workspace_id??o;return g?x(await K(r,`${P(c.project_id??s())}/workspaces/ai/query`,{body:{query:c.query,workspace_id:g}})):v("No workspace_id given and none in config. Call d360_list_workspaces first.")}catch(g){return v(g)}}),w=S("d360_sync_status","Deterministic drift report: local docs tree vs Document360, classified against the sync bases in d360-category-map.json. Call this BEFORE any docs gap/coverage analysis \u2014 it proves whether the local docs tree can be trusted as the Document360 inventory (no need to fetch article content). Statuses: local-ahead (push pending), remote-ahead (portal edit to pull), conflict, untracked-local/remote, deleted-local/remote, unknown-base (no base recorded yet).",{},async()=>{try{if(!t.cwd)return v("Sync status needs a repo context (no cwd configured for this session).");let c=await St({cwd:t.cwd,profileName:e.name});return x({profile:c.profile,docsRoot:c.docsRoot,generatedAt:c.generatedAt,counts:c.counts,attention:c.entries.filter(g=>g.status!=="in-sync")})}catch(c){return v(c)}}),R=S("d360_repo_inventory","Deterministic inventory of candidate source folders for documentation scope (no file reads of your own needed). Returns project-level folders with: path, fileCount, detected stacks, a `recommended` flag (user-facing surfaces pre-recommended; tests/infrastructure not), and a one-line reason. Use this when analyzing a large/multi-project repo to decide which folders back the docs \u2014 it is the same data the /scope picker pre-ticks, so your recommendation and the picker agree. Cheap; safe to call before reading any source.",{},async()=>{try{return t.cwd?x(Pt(t.cwd)):v("Repo inventory needs a repo context (no cwd configured for this session).")}catch(c){return v(c)}}),z=S("d360_estimate_cost",'Deterministic, no-network token/cost ESTIMATE for a bulk operation. Call this BEFORE you propose a repo-scale run (convert / rewrite / publish / audit MANY articles) so you can show the user the article count and a cost band and get an explicit go-ahead \u2014 see the "Confirm before bulk or irreversible actions" rule. It sizes work from local file BYTES (coarse \u2014 returns [low, high] bands, not a quote). Omit `paths` to estimate over every article tracked in d360-category-map.json; pass `paths` for a subset. Cheap and read-only; safe to call before any write.',{op:m.enum(["convert","rewrite","publish","audit"]).describe("What you intend to do to each article (drives the output-size heuristic)."),paths:m.array(m.string()).optional().describe("Repo-relative .md paths. Omit or leave empty to estimate over all articles in the category map.")},async({op:c,paths:g})=>{try{if(!t.cwd)return v("Cost estimate needs a repo context (no cwd configured for this session).");let k;if(g&&g.length>0)k=g.map(_=>_.replace(/\\/g,"/"));else{let _=$(t.cwd,e.name);if(k=_?Object.keys(_.articles):[],k.length===0)return v("No articles are tracked in d360-category-map.json for this profile, and no `paths` were given. Pass `paths` explicitly (the repo-relative .md files you intend to operate on).")}let E=k.map(_=>{let D=0;try{D=sn($e(t.cwd,_)).size}catch{D=0}return{path:_,bytes:D}});return x(Et({files:E,op:c,model:t.model}))}catch(k){return v(k)}}),C=S("d360_create_category","Create a category (a docs folder). Returns the new category id.",{name:m.string(),workspace_id:m.string().optional(),parent_category_id:m.string().optional().describe("Omit for a top-level category."),content:m.string().optional(),slug:m.string().optional(),order:m.number().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/categories`,{body:{name:c.name,workspace_id:i(c.workspace_id),parent_category_id:c.parent_category_id,content:c.content,slug:c.slug,order:c.order,content_type:"markdown"}}))}catch(k){return v(k)}}),Q=S("d360_create_article","Create a DRAFT article in a category. The body is always Markdown (product rule \u2014 we never create WYSIWYG/Block articles). Returns the new article id. Does not publish.",{title:m.string(),category_id:m.string(),content:m.string().optional().describe("Markdown body."),workspace_id:m.string().optional(),slug:m.string().optional(),order:m.number().optional(),project_id:m.string().optional(),local_path:m.string().optional().describe("Repo-relative path of the local .md this article mirrors (e.g. user-docs/01-intro/01-a.md). When given, the new article id + sync base are recorded into d360-category-map.json automatically \u2014 do not edit the articles map yourself.")},async c=>{let g=d();if(g)return g;try{let k=c.project_id??s(),E=c.content;typeof E=="string"&&c.local_path&&t.cwd&&(E=Ie(E,c.local_path,$(t.cwd,e.name)).content);let _=await K(r,`${P(k)}/articles/bulk`,{body:{articles:[{title:c.title,category_id:c.category_id,workspace_id:i(c.workspace_id),content:E,slug:c.slug,order:c.order,content_type:"markdown"}]}}),D=Array.isArray(_)?_[0]:void 0;if(c.local_path&&typeof D?.id=="string"){let I;try{let W=await O(r,`${P(k)}/articles/${D.id}`,{});I=typeof W?.url=="string"?W.url:void 0}catch{}a(c.local_path,{id:D.id,remoteContentHash:Y(c.title,E),remoteVersion:1,...I?{url:I}:{}})}return x(_)}catch(k){return v(k)}}),j=S("d360_update_article","Update an article's title/content/category. Edits the latest draft by default; set auto_fork to safely edit a published article (creates a new draft version).",{article_id:m.string(),title:m.string().optional(),content:m.string().optional().describe("Markdown body."),category_id:m.string().optional(),hidden:m.boolean().optional(),version_number:m.number().optional(),auto_fork:m.boolean().optional().describe("If the target version is published, fork a new draft instead of erroring."),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{let{article_id:k,project_id:E,..._}=c,D=E??s(),I=typeof _.content=="string"&&t.cwd?$(t.cwd,e.name):null,W=I?kt(I,k):null;typeof _.content=="string"&&W&&I&&(_.content=Ie(_.content,W,I).content);let G=null;if(typeof _.content=="string"&&t.onUiEvent)try{G=await O(r,`${P(D)}/articles/${k}`,{query:{content_mode:"raw"}})}catch{}let Be=e.project.languageCode??G?.lang_code,qe=await mt(r,`${P(D)}/articles/${k}`,{body:_,...Be?{query:{lang_code:Be}}:{}});if(G&&typeof _.content=="string"&&(G.content??"")!==_.content&&t.onUiEvent?.({type:"article_diff",articleId:k,title:G.title??null,oldContent:G.content??"",newContent:_.content}),typeof _.content=="string"&&W)try{let X=qe,Fe=_.title??X?.title??G?.title;typeof Fe=="string"&&a(W,{id:k,remoteContentHash:Y(Fe,_.content),...typeof X?.latest_version=="number"?{remoteVersion:X.latest_version}:{},...typeof X?.modified_at=="string"?{remoteModifiedAt:X.modified_at}:{}})}catch{}return x(qe)}catch(k){return v(k)}}),U=S("d360_fork_article","Fork a published article into a new draft version \u2014 the safe way to start editing live content.",{article_id:m.string(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/fork`))}catch(k){return v(k)}}),J=S("d360_publish_article","Publish an article version. This makes the draft live to readers \u2014 only call when the user explicitly asks to publish.",{article_id:m.string(),version_number:m.number(),workspace_id:m.string().optional(),message:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/publish`,{body:{workspace_id:i(c.workspace_id),version_number:c.version_number,message:c.message}}))}catch(k){return v(k)}}),T=S("d360_unpublish_article","Unpublish an article version, reverting it to draft (removes it from readers).",{article_id:m.string(),version_number:m.number().describe("The published version to unpublish (see d360_get_article)."),workspace_id:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{return x(await K(r,`${P(c.project_id??s())}/articles/${c.article_id}/unpublish`,{body:{workspace_id:i(c.workspace_id),version_number:c.version_number}}))}catch(k){return v(k)}}),Ht=S("d360_upload_drive_file","Upload a local file (e.g. a captured screenshot PNG) to Drive and return its URL for embedding in an article. Uploads to the default folder unless folder_id is given.",{file_path:m.string().describe("Local path to the file (absolute, or relative to the repo)."),folder_id:m.string().optional(),title:m.string().optional(),project_id:m.string().optional()},async c=>{let g=d();if(g)return g;try{let k=c.project_id??s(),E=cn(c.file_path)?c.file_path:$e(process.cwd(),c.file_path),_=c.folder_id;if(!_&&(_=(await O(r,`${P(k)}/drive/folders/default`))?.id,!_))return v("Could not resolve the default Drive folder.");let D=Dt(E),I=new FormData;return I.append("file",new Blob([D]),c.title??an(E)),x(await gt(r,`${P(k)}/drive/folders/${_}/files`,I))}catch(k){return v(k)}}),zt=S("d360_search_drive","Search Drive files (e.g. to reuse an already-uploaded screenshot instead of uploading a duplicate).",{search_keyword:m.string().optional(),allow_images_only:m.boolean().optional(),project_id:m.string().optional()},async c=>{try{return x(await q(r,`${P(c.project_id??s())}/drive/search`,{query:{search_keyword:c.search_keyword,allow_images_only:c.allow_images_only}}))}catch(g){return v(g)}});return ln({name:"document360",version:"0.2.0",instructions:"First-party Document360 tools. The signed-in user's permissions apply server-side. Project/workspace default from the active profile; pass ids explicitly to override. Create articles as DRAFTS; only call d360_publish_article when the user explicitly asks to publish. All articles are Markdown \u2014 the tools enforce this; never attempt WYSIWYG/Block content. To edit a published article, fork it (d360_fork_article) or update with auto_fork. Before any docs gap/coverage analysis, call d360_sync_status to verify the local docs tree matches Document360. When scoping a large repo (which folders back the docs), call d360_repo_inventory for the candidate folders. Before a bulk run (converting/publishing/auditing many articles), call d360_estimate_cost and show the user the count + cost band first. Auth errors mean the session expired \u2014 tell the user to run /login (works inside this session).",tools:[u,l,f,p,y,b,h,w,R,z,C,Q,j,U,J,T,Ht,zt]})}var Ut="CLAUDE.md";function Mt(e){if(!Ue(e))return[];let t=[];for(let r of un(e)){let n=Ne(e,r);if(r===Ut||!dn(n).isDirectory())continue;let o=Ne(n,"SKILL.md");Ue(o)&&t.push({name:r,body:$t(o,"utf8")})}return t}function Ot(e){let t=Ne(e,Ut);return Ue(t)?$t(t,"utf8"):null}function fn(e,t){let r=Ke(),n=Ye(e),o=Ot(r),s=Ot(n),i=Mt(r),a=Mt(n),d=new Set(a.map(f=>f.name)),u=[...i.filter(f=>!d.has(f.name)),...a],l=[];if(o&&l.push(o),s&&l.push(`# Project addendum
|
|
11
11
|
|
|
12
12
|
${s}`),t&&(l.push("# Project configuration"),l.push("```json"),l.push(JSON.stringify(t,null,2)),l.push("```")),u.length>0){l.push("# Capabilities"),l.push('You have the following specialized documentation skills. Activate the one whose preconditions match the user request. If multiple match, run them in order. These skills are INLINE INSTRUCTIONS in this prompt \u2014 "activating" one means following its steps directly. They are NOT registered with any Skill tool; never attempt to invoke them via a Skill/skill-runner tool.');for(let f of u)l.push(`## Skill: ${f.name}`),l.push(f.body)}return l.join(`
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -2,43 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
**Activate when** you've just emitted a `<!-- SCREENSHOT ... -->` placeholder block in an article, OR the user runs `/screenshot <id>`.
|
|
4
4
|
|
|
5
|
-
**Goal:** generate a Playwright `.spec.ts`
|
|
5
|
+
**Goal:** generate a Playwright `.spec.ts` in the configured `captureDir` that the `document360-capture` (alias `d360-capture`) CLI runs against the user's live product.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Read the product FIRST — never guess (non-negotiable)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Every recurring capture failure has traced back to guessing. Before you write a single line, READ the source and ground each of these in it:
|
|
10
|
+
|
|
11
|
+
1. **Routes** — find the router (e.g. the app's route table) and use the EXACT path. Do not assume `/spec` vs `/spec-files`, `/tests` vs `/test`. If you can't find it, say so and ask.
|
|
12
|
+
2. **Selectors** — open the component and use a stable `data-testid` you actually see. Never invent one, and never fall back to `li`, `ul.divide-y li`, or other structural CSS.
|
|
13
|
+
3. **Context selection** — find how the app enters the context the shot needs (selecting a project / workspace / org / etc.). You'll drive it with the capture *scope* (below), not by clicking "the first one".
|
|
14
|
+
4. **Data prerequisites** — determine what state the screen needs to look real (≥1 of something, a specific record, an open modal). This becomes both the placeholder's `prerequisites:` and a runtime guard (below).
|
|
15
|
+
|
|
16
|
+
If a fact isn't in the source, do not fabricate it.
|
|
17
|
+
|
|
18
|
+
## Capture scope — enter a KNOWN context, deterministically
|
|
19
|
+
|
|
20
|
+
The capture profile declares a generic `scope` (key→value) for the prepared demo context — e.g. `{project: "Docs Demo"}`, or `{project, workspace, language}`, or `{org}` — whatever THIS product scopes by (you learned that by reading the source; it is not always "project"). The CLI injects it; read it with the `captureScope()` / `scopeValue(key)` helpers.
|
|
21
|
+
|
|
22
|
+
- Select the context using the scope value (e.g. the project whose name === `scope.project`), via the selection mechanism you read from the source — **not** `.first()`.
|
|
23
|
+
- Fall back to `.first()` ONLY when the relevant scope key is unset, so single-context setups still work.
|
|
24
|
+
|
|
25
|
+
## Data prerequisites — skip clearly, don't time out
|
|
26
|
+
|
|
27
|
+
If the data the shot needs is absent, a 15s selector timeout is a terrible error. Instead, guard it and skip with a reason that tells the user exactly what to stage:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
if ((await rows.count()) === 0) {
|
|
31
|
+
test.skip(true, 'Prerequisite not met: needs ≥1 suite in project "' + (scopeValue('project') ?? '<scope>') + '". Stage it, then re-run.');
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The skip reason MUST match what you wrote in the placeholder's `prerequisites:` line — they're the same fact, surfaced two ways.
|
|
10
36
|
|
|
11
37
|
## Spec template
|
|
12
38
|
|
|
13
39
|
```typescript
|
|
14
40
|
import { test } from '@playwright/test';
|
|
15
|
-
import { waitPastLogin, dumpAnnotations, type Placeholder } from 'document360-capture/helpers';
|
|
41
|
+
import { waitPastLogin, dumpAnnotations, captureScope, scopeValue, type Placeholder } from 'document360-capture/helpers';
|
|
16
42
|
|
|
17
43
|
const PLACEHOLDER: Placeholder = {
|
|
18
44
|
id: '<placeholder.id>',
|
|
19
45
|
saveTo: '<placeholder.save-to>',
|
|
20
|
-
highlight: [
|
|
21
|
-
|
|
22
|
-
],
|
|
23
|
-
annotations: [
|
|
24
|
-
// { target: '<stable selector>', label: '1', text: '<short text>' }
|
|
25
|
-
],
|
|
26
|
-
redact: [
|
|
27
|
-
// one stable selector per placeholder.redact entry
|
|
28
|
-
],
|
|
46
|
+
highlight: [ /* one stable selector per placeholder.highlight entry */ ],
|
|
47
|
+
annotations: [ /* { target: '<stable selector>', label: '1', text: '<short text>' } */ ],
|
|
48
|
+
redact: [ /* one stable selector per placeholder.redact entry */ ],
|
|
29
49
|
};
|
|
30
50
|
|
|
31
51
|
test('<placeholder.id>', async ({ page }) => {
|
|
32
52
|
await page.goto(process.env.CAPTURE_START_URL!);
|
|
33
53
|
await waitPastLogin(page, new RegExp(process.env.CAPTURE_AUTH_BOUNDARY!));
|
|
34
54
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
//
|
|
55
|
+
// 1. Enter the prepared context using the scope (selection mechanism read from source).
|
|
56
|
+
const scope = captureScope();
|
|
57
|
+
// …select the context by scope.<key>; fall back to first only when that key is unset…
|
|
58
|
+
|
|
59
|
+
// 2. Navigate using the EXACT route read from the router (never a guessed path).
|
|
60
|
+
// …
|
|
61
|
+
|
|
62
|
+
// 3. Prerequisite guard — skip with a clear reason instead of timing out.
|
|
63
|
+
// if ((await <rows>.count()) === 0) test.skip(true, 'Prerequisite not met: …');
|
|
38
64
|
|
|
65
|
+
// 4. Reach the target state, settle, capture.
|
|
39
66
|
await page.waitForSelector('<target state selector>', { state: 'visible' });
|
|
40
67
|
await page.waitForTimeout(500);
|
|
41
|
-
|
|
42
68
|
await page.locator('<capture target selector>').screenshot({ path: PLACEHOLDER.saveTo });
|
|
43
69
|
await dumpAnnotations(page, PLACEHOLDER);
|
|
44
70
|
});
|
|
@@ -46,31 +72,27 @@ test('<placeholder.id>', async ({ page }) => {
|
|
|
46
72
|
|
|
47
73
|
## Selector quality rule (non-negotiable)
|
|
48
74
|
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
Stable selectors in priority order:
|
|
51
76
|
1. `[data-testid="..."]` — best.
|
|
52
77
|
2. `[aria-label="..."]` — good.
|
|
53
|
-
3.
|
|
54
|
-
4. Visible text (`text
|
|
78
|
+
3. `role=button[name="..."]` / `<role>:has-text("...")` — acceptable when unique.
|
|
79
|
+
4. Visible text (`text=...`) — acceptable for top-level nav and unique buttons.
|
|
55
80
|
|
|
56
|
-
**Never**
|
|
81
|
+
**Never** CSS classes, `nth-child`, structural `li`/`div.x`, deep DOM paths, or auto-generated IDs (`#mui-12345`).
|
|
57
82
|
|
|
58
|
-
## TODO escape hatch (when stable
|
|
59
|
-
|
|
60
|
-
If a required interaction has no stable selector, do NOT write a fragile one. Instead:
|
|
83
|
+
## TODO escape hatch (when a stable selector truly doesn't exist)
|
|
61
84
|
|
|
85
|
+
Don't write a fragile selector. Instead:
|
|
62
86
|
1. Use `test.skip(...)` instead of `test(...)`.
|
|
63
|
-
2.
|
|
64
|
-
3. Tell the user which file + component to fix
|
|
65
|
-
|
|
66
|
-
The manual intern path in the placeholder still works — only the automated capture is blocked.
|
|
87
|
+
2. Top-of-file comment: `// TODO: add data-testid="<name>" to <Component> at <src/.../File.tsx>. Capture disabled until then.`
|
|
88
|
+
3. Tell the user which file + component to fix and the `data-testid` to add.
|
|
67
89
|
|
|
68
90
|
## Capture region (the placeholder's `capture` field)
|
|
69
91
|
|
|
70
92
|
| `capture` value | Spec uses |
|
|
71
93
|
|---|---|
|
|
72
94
|
| `full-page` | `await page.screenshot({ path, fullPage: true })` |
|
|
73
|
-
| `main-panel` | a stable container selector (read it from the
|
|
95
|
+
| `main-panel` | a stable container selector (read it from the layout source) |
|
|
74
96
|
| `modal-only` | `page.locator('[role="dialog"]').first().screenshot(...)` |
|
|
75
97
|
| `panel-left` / `panel-right` | the project's named-panel selector |
|
|
76
98
|
| `sidebar` | the project's sidebar selector |
|
|
@@ -78,8 +100,9 @@ The manual intern path in the placeholder still works — only the automated cap
|
|
|
78
100
|
## After writing the spec
|
|
79
101
|
|
|
80
102
|
- Report the spec's path.
|
|
81
|
-
-
|
|
82
|
-
- Remind
|
|
103
|
+
- List anything that needs the user: the `data-testid`s a dev must add (TODO hatch), and the **data prerequisites** to stage (project/workspace + the records each shot needs).
|
|
104
|
+
- Remind how to capture (screenshots are a separate tool — not bundled with the writer):
|
|
83
105
|
1. First time only: `npm i -g document360-capture`
|
|
84
|
-
2.
|
|
85
|
-
3. `d360-capture
|
|
106
|
+
2. Set the capture `scope` for the prepared demo context in `.d360-capture.json`.
|
|
107
|
+
3. Once per machine: `d360-capture auth --profile <name>`
|
|
108
|
+
4. `d360-capture capture <id>`.
|