@loicngr/kobo 1.11.9 → 1.11.11
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/AGENTS.md +2 -1
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/server/db/migrations.js +155 -19
- package/dist/server/db/schema.js +93 -0
- package/dist/server/index.js +35 -26
- package/dist/server/routes/dev-server.js +8 -7
- package/dist/server/routes/documents.js +33 -3
- package/dist/server/routes/health.js +16 -15
- package/dist/server/routes/images.js +17 -2
- package/dist/server/routes/workspaces.js +66 -49
- package/dist/server/services/agent/orchestrator.js +8 -1
- package/dist/server/services/auto-loop-service.js +1 -1
- package/dist/server/services/dev-server-service.js +48 -41
- package/dist/server/services/forge/bitbucket/provider.js +6 -11
- package/dist/server/services/forge/github/provider.js +10 -9
- package/dist/server/services/forge/gitlab/provider.js +11 -13
- package/dist/server/services/git-stats-service.js +8 -6
- package/dist/server/services/image-service.js +34 -5
- package/dist/server/services/pr-watcher-service.js +20 -9
- package/dist/server/services/search-service.js +53 -40
- package/dist/server/services/skill-suite-prompts.js +72 -1
- package/dist/server/services/websocket-service.js +8 -3
- package/dist/server/services/workspace-service.js +81 -10
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +89 -22
- package/dist/server/utils/safe-path.js +14 -0
- package/dist/shared/skill-suite-prompts.js +12 -1
- package/package.json +22 -22
- package/src/client/dist/pwa/assets/ActivityFeed-BNDQPIiW.js +8 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-Dv06XbgS.css → ActivityFeed-BrjeLlhi.css} +1 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DWeiMCL1.js +1 -0
- package/src/client/dist/pwa/assets/{ClosePopup-BZ1O0Jxe.js → ClosePopup-Bl8KdN_j.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-qCRXIoz-.js +2 -0
- package/src/client/dist/pwa/assets/DiffViewer-BwfYKTny.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-D9FN4RS8.css → DiffViewer-CS3P9Ngi.css} +1 -1
- package/src/client/dist/pwa/assets/HealthPage-zm1qy4bo.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-BDVUkruC.js +19 -0
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
- package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-BKZW3k57.js +1 -0
- package/src/client/dist/pwa/assets/QChip-CMB-IbQd.js +1 -0
- package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
- package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
- package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-DCm1FiOk.js +36 -0
- package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
- package/src/client/dist/pwa/assets/QTooltip-8fQLc_4N.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-B-prQgu3.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-BJZWZYPC.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-CbasGyLP.js +16 -0
- package/src/client/dist/pwa/assets/TouchPan-CmmS3gg6.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-B9I2mQJE.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
- package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
- package/src/client/dist/pwa/assets/cssMode-B4oUyX4f.js +1 -0
- package/src/client/dist/pwa/assets/documents-UdlveYUp.js +1 -0
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-ByJzr33G.js +847 -0
- package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
- package/src/client/dist/pwa/assets/expand-template-zfuUsWH3.js +1 -0
- package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-Dgrv0PyH.js → freemarker2-DurjKE7N.js} +1 -1
- package/src/client/dist/pwa/assets/handlebars-gwHbbDT2.js +1 -0
- package/src/client/dist/pwa/assets/html-DDNV7Gkm.js +1 -0
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
- package/src/client/dist/pwa/assets/htmlMode-BxdPTVhK.js +1 -0
- package/src/client/dist/pwa/assets/i18n-Z1bkxUCG.js +1 -0
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
- package/src/client/dist/pwa/assets/index-M0ntkdkQ.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-IK9qSsfx.js → javascript-Dp0eAm2m.js} +1 -1
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
- package/src/client/dist/pwa/assets/{jsonMode-D_Vdxb-B.js → jsonMode-DLllFEMT.js} +3 -3
- package/src/client/dist/pwa/assets/{kobo-commands-zljfAram.js → kobo-commands-So0Do2g1.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CfEpJmm_.js +1 -0
- package/src/client/dist/pwa/assets/liquid-BVwIeblL.js +1 -0
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CExl7Xgb.js +4 -0
- package/src/client/dist/pwa/assets/mdx-ydpr14Ec.js +1 -0
- package/src/client/dist/pwa/assets/network-auth-CxxR2FZL.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-BqHyeaCb.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-6xNNY9u6.js → project-color-Dl2gXBlN.js} +1 -1
- package/src/client/dist/pwa/assets/python-BParijRW.js +1 -0
- package/src/client/dist/pwa/assets/razor-DmywR0-y.js +1 -0
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
- package/src/client/dist/pwa/assets/render-chat-markdown-BQBEE7Bn.js +67 -0
- package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
- package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
- package/src/client/dist/pwa/assets/{tsMode-DiiMWOVl.js → tsMode-C51hBxK8.js} +1 -1
- package/src/client/dist/pwa/assets/typescript-C9LzA9s6.js +1 -0
- package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding--VIOMbMd.js +2 -0
- package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
- package/src/client/dist/pwa/assets/use-quasar-B6Aitpct.js +1 -0
- package/src/client/dist/pwa/assets/vs-BJBy3q9a.js +56 -0
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
- package/src/client/dist/pwa/assets/workers-B89GKCYK.js +1 -0
- package/src/client/dist/pwa/assets/xml-C2D1fXDa.js +1 -0
- package/src/client/dist/pwa/assets/yaml-8QFrUEL6.js +1 -0
- package/src/client/dist/pwa/index.html +8 -8
- package/src/client/dist/pwa/manifest.json +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-Bs1uccfa.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-nW4vA3qx.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BrdHh0Th.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-CRIBi2Vx.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-DhtJQzG2.js +0 -8
- package/src/client/dist/pwa/assets/HealthPage-CExFCl_7.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Coqyj1FP.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-WYZAjpD2.js +0 -37
- package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
- package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-CqyoLg5U.js +0 -1
- package/src/client/dist/pwa/assets/QChip-DMYWc1jc.js +0 -1
- package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
- package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
- package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
- package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-CAa3syxF.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
- package/src/client/dist/pwa/assets/QTooltip-6IQJP1_Y.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CQtm05UQ.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-B38IzmSs.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-BXiWr2ba.css +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-BqWpfNZP.js +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-C939psBh.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CFF5Ghug.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-dS-Nc6QU.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-C5rzY2hF.js +0 -1
- package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
- package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/src/client/dist/pwa/assets/cssMode-p60S_iK6.js +0 -1
- package/src/client/dist/pwa/assets/documents-CzlF-PFm.js +0 -1
- package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
- package/src/client/dist/pwa/assets/editor.api2-DfOm2dM5.js +0 -872
- package/src/client/dist/pwa/assets/editor.main-6eriWmTe.js +0 -6
- package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
- package/src/client/dist/pwa/assets/expand-template-B_RAt6BV.js +0 -1
- package/src/client/dist/pwa/assets/formatters-BoLOujHM.js +0 -1
- package/src/client/dist/pwa/assets/handlebars-D0XTaj97.js +0 -1
- package/src/client/dist/pwa/assets/html-DkYxFVm6.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
- package/src/client/dist/pwa/assets/htmlMode-Dj3mtvXI.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dzzn4YfF.js +0 -1
- package/src/client/dist/pwa/assets/index-C06a940j.js +0 -84
- package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
- package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
- package/src/client/dist/pwa/assets/layout-BAnzeEPY.js +0 -1
- package/src/client/dist/pwa/assets/liquid-DURTrym0.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-Bki_90YN.js +0 -4
- package/src/client/dist/pwa/assets/mdx-z7FKEEaF.js +0 -1
- package/src/client/dist/pwa/assets/monaco.contribution-D52lvpFV.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-DrGWXKl2.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DX42h-5U.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DMHgg-69.js +0 -1
- package/src/client/dist/pwa/assets/python-DHQ3U-kG.js +0 -1
- package/src/client/dist/pwa/assets/razor-DMlreu1b.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-0Vj9B94m.js +0 -66
- package/src/client/dist/pwa/assets/typescript-0wxHr6dC.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding--YqDGUQR.js +0 -2
- package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
- package/src/client/dist/pwa/assets/use-quasar-oX1TyrPM.js +0 -1
- package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
- package/src/client/dist/pwa/assets/workers-D0OEUSgt.js +0 -1
- package/src/client/dist/pwa/assets/xml-C7PczWV5.js +0 -1
- package/src/client/dist/pwa/assets/yaml-Dj6QPnyC.js +0 -1
|
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
7
|
+
function cliOptions(repoPath) {
|
|
8
|
+
return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
|
|
9
|
+
}
|
|
6
10
|
/** Map GitLab MR state to the normalised PrSnapshot state. */
|
|
7
11
|
function mapState(state) {
|
|
8
12
|
if (state === 'merged')
|
|
@@ -100,10 +104,7 @@ function mapGlabJobToCheck(job) {
|
|
|
100
104
|
*/
|
|
101
105
|
async function fetchGlabCi(repoPath, branch) {
|
|
102
106
|
try {
|
|
103
|
-
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'],
|
|
104
|
-
cwd: repoPath,
|
|
105
|
-
encoding: 'utf-8',
|
|
106
|
-
});
|
|
107
|
+
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], cliOptions(repoPath));
|
|
107
108
|
const raw = stdout.trim();
|
|
108
109
|
if (!raw)
|
|
109
110
|
return { rollup: null, checks: [] };
|
|
@@ -138,7 +139,7 @@ export const gitlabProvider = {
|
|
|
138
139
|
},
|
|
139
140
|
async isAvailable(repoPath) {
|
|
140
141
|
try {
|
|
141
|
-
await execFileAsync('glab', ['auth', 'status'],
|
|
142
|
+
await execFileAsync('glab', ['auth', 'status'], cliOptions(repoPath));
|
|
142
143
|
return { available: true };
|
|
143
144
|
}
|
|
144
145
|
catch (err) {
|
|
@@ -147,10 +148,7 @@ export const gitlabProvider = {
|
|
|
147
148
|
},
|
|
148
149
|
async getPrStatus(repoPath, branch) {
|
|
149
150
|
try {
|
|
150
|
-
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'],
|
|
151
|
-
cwd: repoPath,
|
|
152
|
-
encoding: 'utf-8',
|
|
153
|
-
});
|
|
151
|
+
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], cliOptions(repoPath));
|
|
154
152
|
const raw = stdout.trim();
|
|
155
153
|
if (!raw)
|
|
156
154
|
return null;
|
|
@@ -176,19 +174,19 @@ export const gitlabProvider = {
|
|
|
176
174
|
'--description',
|
|
177
175
|
opts.body,
|
|
178
176
|
'--yes',
|
|
179
|
-
],
|
|
177
|
+
], cliOptions(repoPath));
|
|
180
178
|
const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
|
|
181
179
|
if (!match)
|
|
182
180
|
throw new Error('Could not parse MR URL from glab output');
|
|
183
181
|
return { url: match[0], number: Number.parseInt(match[1], 10) };
|
|
184
182
|
},
|
|
185
183
|
async changePrBase(repoPath, base) {
|
|
186
|
-
await execFileAsync('glab', ['mr', 'update', '--target-branch', base],
|
|
184
|
+
await execFileAsync('glab', ['mr', 'update', '--target-branch', base], cliOptions(repoPath));
|
|
187
185
|
},
|
|
188
186
|
async mergeRequest(repoPath, number) {
|
|
189
|
-
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'],
|
|
187
|
+
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], cliOptions(repoPath));
|
|
190
188
|
},
|
|
191
189
|
async deleteRemoteBranch(repoPath, branch) {
|
|
192
|
-
await execFileAsync('git', ['push', 'origin', '--delete', branch],
|
|
190
|
+
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
193
191
|
},
|
|
194
192
|
};
|
|
@@ -10,13 +10,15 @@ import { resolveForge } from './forge/resolve.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function computeGitStats(workspace, prSnapshot) {
|
|
12
12
|
const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
|
|
13
|
-
const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
|
|
14
|
-
const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
|
|
15
|
-
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
|
|
16
|
-
const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
|
|
17
|
-
const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
|
|
18
13
|
const forgeProvider = getForgeProvider(resolveForge(projectPath));
|
|
19
|
-
const availability = await
|
|
14
|
+
const [commitCount, behindCount, diffStats, unpushedCount, workingTree, availability] = await Promise.all([
|
|
15
|
+
gitOps.getCommitCountAsync(worktreePath, sourceBranch, workingBranch),
|
|
16
|
+
gitOps.getCommitsBehindAsync(worktreePath, sourceBranch, workingBranch),
|
|
17
|
+
gitOps.getStructuredDiffStatsBetweenAsync(worktreePath, sourceBranch, workingBranch),
|
|
18
|
+
gitOps.getUnpushedCountAsync(worktreePath, workingBranch),
|
|
19
|
+
gitOps.getWorkingTreeStatusAsync(worktreePath),
|
|
20
|
+
forgeProvider.isAvailable(worktreePath),
|
|
21
|
+
]);
|
|
20
22
|
return {
|
|
21
23
|
commitCount,
|
|
22
24
|
behindCount,
|
|
@@ -6,6 +6,29 @@ const IMAGES_DIR = '.ai/images';
|
|
|
6
6
|
const INDEX_FILE = 'index.json';
|
|
7
7
|
// Per-worktree lock to serialize index.json writes
|
|
8
8
|
const locks = new Map();
|
|
9
|
+
function ensureRealImagesDirectory(worktreePath) {
|
|
10
|
+
const realWorktree = fs.realpathSync(worktreePath);
|
|
11
|
+
let current = worktreePath;
|
|
12
|
+
for (const segment of ['.ai', 'images']) {
|
|
13
|
+
current = path.join(current, segment);
|
|
14
|
+
if (fs.existsSync(current)) {
|
|
15
|
+
const stat = fs.lstatSync(current);
|
|
16
|
+
if (stat.isSymbolicLink())
|
|
17
|
+
throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
|
|
18
|
+
if (!stat.isDirectory())
|
|
19
|
+
throw new Error(`Image storage path is not a directory: ${current}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
fs.mkdirSync(current);
|
|
23
|
+
}
|
|
24
|
+
const realCurrent = fs.realpathSync(current);
|
|
25
|
+
const relative = path.relative(realWorktree, realCurrent);
|
|
26
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
27
|
+
throw new Error('Image storage path escapes the worktree');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return fs.realpathSync(current);
|
|
31
|
+
}
|
|
9
32
|
function withLock(worktreePath, fn) {
|
|
10
33
|
const prev = locks.get(worktreePath) ?? Promise.resolve();
|
|
11
34
|
// The second argument to .then() means: even if the previous operation in the
|
|
@@ -18,6 +41,8 @@ function readIndex(imagesDir) {
|
|
|
18
41
|
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
19
42
|
if (!fs.existsSync(indexPath))
|
|
20
43
|
return [];
|
|
44
|
+
if (fs.lstatSync(indexPath).isSymbolicLink())
|
|
45
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
21
46
|
try {
|
|
22
47
|
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
23
48
|
}
|
|
@@ -27,7 +52,13 @@ function readIndex(imagesDir) {
|
|
|
27
52
|
}
|
|
28
53
|
}
|
|
29
54
|
function writeIndex(imagesDir, entries) {
|
|
30
|
-
|
|
55
|
+
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
56
|
+
if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
|
|
57
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
58
|
+
}
|
|
59
|
+
const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
|
|
60
|
+
fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
|
|
61
|
+
fs.renameSync(tempPath, indexPath);
|
|
31
62
|
}
|
|
32
63
|
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
33
64
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
@@ -39,9 +70,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
39
70
|
throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
40
71
|
}
|
|
41
72
|
const uid = nanoid(10);
|
|
42
|
-
const imagesDir =
|
|
43
|
-
// mkdirSync is idempotent — safe to call outside the lock
|
|
44
|
-
fs.mkdirSync(imagesDir, { recursive: true });
|
|
73
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
45
74
|
const filename = `${uid}.${ext}`;
|
|
46
75
|
await withLock(worktreePath, () => {
|
|
47
76
|
// Write the image file inside the lock so both the file write and index
|
|
@@ -55,7 +84,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
55
84
|
}
|
|
56
85
|
/** Delete an image by UID from disk and the index. */
|
|
57
86
|
export async function deleteImage(worktreePath, uid) {
|
|
58
|
-
const imagesDir =
|
|
87
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
59
88
|
await withLock(worktreePath, () => {
|
|
60
89
|
const entries = readIndex(imagesDir);
|
|
61
90
|
const idx = entries.findIndex((e) => e.uid === uid);
|
|
@@ -18,8 +18,19 @@ import { purgeWorktree } from './worktree-purge-service.js';
|
|
|
18
18
|
// it is recorded but NOT acted upon — prevents re-archiving manually
|
|
19
19
|
// unarchived workspaces.
|
|
20
20
|
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
21
|
+
const WORKSPACE_CHECK_CONCURRENCY = 4;
|
|
21
22
|
let timer = null;
|
|
22
23
|
let checking = false;
|
|
24
|
+
async function runBounded(items, concurrency, worker) {
|
|
25
|
+
let nextIndex = 0;
|
|
26
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
27
|
+
while (nextIndex < items.length) {
|
|
28
|
+
const item = items[nextIndex++];
|
|
29
|
+
await worker(item);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
await Promise.all(workers);
|
|
33
|
+
}
|
|
23
34
|
/** Tracks the last known PR snapshot per workspace, used to detect transitions
|
|
24
35
|
* (state, base, reviewDecision). */
|
|
25
36
|
const lastKnownPr = new Map();
|
|
@@ -110,11 +121,11 @@ export async function checkPrStatuses() {
|
|
|
110
121
|
lastKnownGitStats.delete(id);
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
|
-
|
|
124
|
+
await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
|
|
114
125
|
// Without this guard, every git/forge spawn below fails with ENOENT and
|
|
115
126
|
// floods the logs when a worktree was deleted externally.
|
|
116
127
|
if (!fs.existsSync(ws.worktreePath))
|
|
117
|
-
|
|
128
|
+
return;
|
|
118
129
|
try {
|
|
119
130
|
// Opt-out: skip the forge call entirely for a workspace with PR-watch
|
|
120
131
|
// disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
|
|
@@ -143,7 +154,7 @@ export async function checkPrStatuses() {
|
|
|
143
154
|
console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
144
155
|
// Leave the cache untouched so the next tick retries — and skip
|
|
145
156
|
// stats too, since they'd be computed against the stale base.
|
|
146
|
-
|
|
157
|
+
return;
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
}
|
|
@@ -158,7 +169,7 @@ export async function checkPrStatuses() {
|
|
|
158
169
|
console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
159
170
|
}
|
|
160
171
|
if (!pr)
|
|
161
|
-
|
|
172
|
+
return;
|
|
162
173
|
const prev = lastKnownPr.get(ws.id);
|
|
163
174
|
// We delay updating `lastKnownPr` until after the actions succeed.
|
|
164
175
|
// Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
|
|
@@ -179,13 +190,13 @@ export async function checkPrStatuses() {
|
|
|
179
190
|
// (The defensive base preservation from the no-base branch doesn't apply here
|
|
180
191
|
// because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
|
|
181
192
|
lastKnownPr.set(ws.id, pr);
|
|
182
|
-
|
|
193
|
+
return;
|
|
183
194
|
}
|
|
184
195
|
console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
|
|
185
196
|
// Best-effort cleanup (same as manual archive): stop dev server + terminal.
|
|
186
197
|
// Agent is already not running here (guarded above).
|
|
187
198
|
try {
|
|
188
|
-
stopDevServer(ws.id);
|
|
199
|
+
await stopDevServer(ws.id);
|
|
189
200
|
}
|
|
190
201
|
catch (err) {
|
|
191
202
|
console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
@@ -217,7 +228,7 @@ export async function checkPrStatuses() {
|
|
|
217
228
|
console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
|
-
|
|
231
|
+
return; // do not run base-change detection on a workspace we just archived
|
|
221
232
|
}
|
|
222
233
|
// Review, CI, mergeability, and readiness transitions (only on OPEN PRs;
|
|
223
234
|
// first-sight is silent). Each check is independent so simultaneous
|
|
@@ -254,7 +265,7 @@ export async function checkPrStatuses() {
|
|
|
254
265
|
if (pr.state !== 'OPEN' || !pr.base) {
|
|
255
266
|
const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
|
|
256
267
|
lastKnownPr.set(ws.id, next);
|
|
257
|
-
|
|
268
|
+
return;
|
|
258
269
|
}
|
|
259
270
|
lastKnownPr.set(ws.id, pr);
|
|
260
271
|
// Emit the base-change event AFTER the snapshot is committed so a
|
|
@@ -272,7 +283,7 @@ export async function checkPrStatuses() {
|
|
|
272
283
|
catch (err) {
|
|
273
284
|
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
274
285
|
}
|
|
275
|
-
}
|
|
286
|
+
});
|
|
276
287
|
}
|
|
277
288
|
/**
|
|
278
289
|
* On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getDb } from '../db/index.js';
|
|
2
2
|
/** Event types that carry user-authored or assistant-authored readable text. */
|
|
3
|
-
const SEARCHABLE_TYPES = ['user:message', 'agent:output'];
|
|
3
|
+
const SEARCHABLE_TYPES = ['user:message', 'agent:event', 'agent:output'];
|
|
4
4
|
const SNIPPET_CONTEXT = 100; // chars on each side of the match
|
|
5
5
|
/**
|
|
6
6
|
* Extract the readable text content of a ws_events payload, or `null` when
|
|
@@ -29,6 +29,9 @@ function extractReadableText(type, payload) {
|
|
|
29
29
|
return parts.length > 0 ? parts.join('\n') : null;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
if (type === 'agent:event' && p.kind === 'message:text') {
|
|
33
|
+
return typeof p.text === 'string' ? p.text : null;
|
|
34
|
+
}
|
|
32
35
|
return null;
|
|
33
36
|
}
|
|
34
37
|
function buildSnippet(text, matchIndex, query) {
|
|
@@ -56,49 +59,59 @@ export function searchEvents(query, options = {}) {
|
|
|
56
59
|
const db = getDb();
|
|
57
60
|
const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
|
|
58
61
|
const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
// realistic datasets, without scanning the entire table.
|
|
62
|
-
const dbLimit = Math.max(limit * 3, 100);
|
|
63
|
-
const rows = db
|
|
64
|
-
.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
|
|
65
|
-
w.name AS workspace_name, w.archived_at
|
|
66
|
-
FROM ws_events e
|
|
67
|
-
JOIN workspaces w ON e.workspace_id = w.id
|
|
68
|
-
WHERE e.type IN (${typePlaceholders})
|
|
69
|
-
AND e.payload LIKE ?
|
|
70
|
-
${archiveFilter}
|
|
71
|
-
ORDER BY e.created_at DESC
|
|
72
|
-
LIMIT ?`)
|
|
73
|
-
.all(...SEARCHABLE_TYPES, likePattern, dbLimit);
|
|
62
|
+
const escapedQuery = trimmed.replace(/[\\%_]/g, '\\$&');
|
|
63
|
+
const likePattern = `%${escapedQuery}%`;
|
|
74
64
|
const needle = trimmed.toLowerCase();
|
|
75
65
|
const results = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
66
|
+
const batchSize = Math.min(Math.max(limit * 3, 100), 500);
|
|
67
|
+
const statement = db.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
|
|
68
|
+
w.name AS workspace_name, w.archived_at
|
|
69
|
+
FROM ws_events e
|
|
70
|
+
JOIN workspaces w ON e.workspace_id = w.id
|
|
71
|
+
WHERE e.type IN (${typePlaceholders})
|
|
72
|
+
AND e.payload LIKE ? ESCAPE '\\'
|
|
73
|
+
${archiveFilter}
|
|
74
|
+
AND (? IS NULL OR e.created_at < ? OR (e.created_at = ? AND e.id < ?))
|
|
75
|
+
ORDER BY e.created_at DESC, e.id DESC
|
|
76
|
+
LIMIT ?`);
|
|
77
|
+
let cursorCreatedAt = null;
|
|
78
|
+
let cursorId = null;
|
|
79
|
+
while (results.length < limit) {
|
|
80
|
+
const rows = statement.all(...SEARCHABLE_TYPES, likePattern, cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorId, batchSize);
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(row.payload);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const text = extractReadableText(row.type, parsed);
|
|
90
|
+
if (!text)
|
|
91
|
+
continue;
|
|
92
|
+
const idx = text.toLowerCase().indexOf(needle);
|
|
93
|
+
if (idx < 0)
|
|
94
|
+
continue;
|
|
95
|
+
results.push({
|
|
96
|
+
eventId: row.id,
|
|
97
|
+
sessionId: row.session_id,
|
|
98
|
+
workspaceId: row.workspace_id,
|
|
99
|
+
workspaceName: row.workspace_name,
|
|
100
|
+
archived: row.archived_at !== null,
|
|
101
|
+
type: row.type,
|
|
102
|
+
timestamp: row.created_at,
|
|
103
|
+
snippet: buildSnippet(text, idx, trimmed),
|
|
104
|
+
});
|
|
105
|
+
if (results.length >= limit)
|
|
106
|
+
break;
|
|
83
107
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (idx < 0)
|
|
89
|
-
continue;
|
|
90
|
-
results.push({
|
|
91
|
-
eventId: row.id,
|
|
92
|
-
sessionId: row.session_id,
|
|
93
|
-
workspaceId: row.workspace_id,
|
|
94
|
-
workspaceName: row.workspace_name,
|
|
95
|
-
archived: row.archived_at !== null,
|
|
96
|
-
type: row.type,
|
|
97
|
-
timestamp: row.created_at,
|
|
98
|
-
snippet: buildSnippet(text, idx, trimmed),
|
|
99
|
-
});
|
|
100
|
-
if (results.length >= limit)
|
|
108
|
+
if (rows.length < batchSize)
|
|
109
|
+
break;
|
|
110
|
+
const last = rows.at(-1);
|
|
111
|
+
if (!last)
|
|
101
112
|
break;
|
|
113
|
+
cursorCreatedAt = last.created_at;
|
|
114
|
+
cursorId = last.id;
|
|
102
115
|
}
|
|
103
116
|
return results;
|
|
104
117
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AGNOSTIC_AUTO_LOOP_GROOMING_INTRO, AGNOSTIC_AUTO_LOOP_REVIEW_GATE, AGNOSTIC_BRAINSTORMING_INSTRUCTION, AGNOSTIC_QA_PROMPT_TEMPLATE, AGNOSTIC_REVIEW_TEMPLATE, GROOMING_INTRO_COMBINED, GROOMING_INTRO_GSTACK, GROOMING_INTRO_SUPERPOWERS, } from '../../shared/skill-suite-prompts.js';
|
|
1
|
+
import { AGNOSTIC_AUTO_LOOP_GROOMING_INTRO, AGNOSTIC_AUTO_LOOP_REVIEW_GATE, AGNOSTIC_BRAINSTORMING_INSTRUCTION, AGNOSTIC_QA_PROMPT_TEMPLATE, AGNOSTIC_REVIEW_TEMPLATE, GROOMING_INTRO_ALL, GROOMING_INTRO_COMBINED, GROOMING_INTRO_ECC, GROOMING_INTRO_GSTACK, GROOMING_INTRO_SUPERPOWERS, } from '../../shared/skill-suite-prompts.js';
|
|
2
2
|
// Headers and body shared with the agnostic baseline. We reconstruct them
|
|
3
3
|
// locally here so the suite-specific text can sit between header and body
|
|
4
4
|
// in the same shape across all 3 suites.
|
|
@@ -73,6 +73,19 @@ For reproducible regression coverage that runs on every PR, prefer **Cypress** s
|
|
|
73
73
|
'3. If you need fine control (e.g. skip a step, run one in isolation), invoke the individual skills instead: `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/plan-devex-review`.\n' +
|
|
74
74
|
'4. Wait for explicit user approval on the final plan before announcing brainstorming is done.',
|
|
75
75
|
};
|
|
76
|
+
export const ECC_PROMPTS = {
|
|
77
|
+
reviewTemplate: REVIEW_HEADER +
|
|
78
|
+
'If a code-review skill is available (e.g. `ecc:code-review`, or dispatch a subagent via the Task tool with `subagent_type: "ecc:code-reviewer"`), invoke it to drive this review. Otherwise follow the steps below directly.\n\n' +
|
|
79
|
+
REVIEW_BODY,
|
|
80
|
+
autoLoopReviewGate: 'Code review gate — BEFORE marking the task done, dispatch an independent code-reviewer subagent via the Task tool with `subagent_type: "ecc:code-reviewer"` (or run the `ecc:code-review` / `ecc:quality-gate` skill if you prefer a driven workflow over a bare subagent). Brief the reviewer with: what you just implemented, the task title, and the commit SHA (via `git rev-parse HEAD`). Ask specifically whether the change matches the task scope, whether edge cases are handled, and whether the commit is clean.',
|
|
81
|
+
autoLoopGroomingIntro: GROOMING_INTRO_ECC,
|
|
82
|
+
qaPromptTemplate: 'QA pass for workspace "{{workspace_name}}" in project {{project_name}}.\n\nBranch: {{branch_name}}\nStaging URL: {{staging_url}}\n\nIf a QA-style skill is available in this environment (e.g. `ecc:browser-qa`, or dispatch a subagent via the Task tool with `subagent_type: "ecc:e2e-runner"`), use it to exercise the staging URL. Otherwise, fall back to manually scripting the smoke checks and recording your findings as a bug report.',
|
|
83
|
+
brainstormingInstruction: 'Brainstorm the implementation approach using the ECC pipeline:\n' +
|
|
84
|
+
'1. Run the `ecc:plan` skill (or dispatch a subagent via the Task tool with `subagent_type: "ecc:planner"`) — it expands the request into a full spec with features, sprints, and evaluation criteria before any code. Ask clarifying questions and wait for explicit user approval on the design before moving on.\n' +
|
|
85
|
+
'2. Use `ecc:tdd-workflow` to shape the approved design into a test-first implementation plan.\n' +
|
|
86
|
+
'3. If you encounter a bug or unexpected behaviour during exploration, dispatch a subagent via the Task tool with `subagent_type: "ecc:architect"` (or the relevant language-specific `ecc:*-reviewer` agent) rather than guessing.\n' +
|
|
87
|
+
"Do NOT skip the skills or rationalise around them — that's how the rigour gets lost.",
|
|
88
|
+
};
|
|
76
89
|
export const AGNOSTIC_PROMPTS = {
|
|
77
90
|
reviewTemplate: AGNOSTIC_REVIEW_TEMPLATE,
|
|
78
91
|
autoLoopReviewGate: AGNOSTIC_AUTO_LOOP_REVIEW_GATE,
|
|
@@ -132,6 +145,60 @@ For reproducible regression coverage that runs on every PR, prefer **Cypress** s
|
|
|
132
145
|
' - Apply any changes the reviews recommend before moving on.\n' +
|
|
133
146
|
'5. Wait for explicit user approval on the final reviewed plan before announcing brainstorming is done.',
|
|
134
147
|
};
|
|
148
|
+
// ── Triple combo: superpowers + gstack + ecc ─────────────────────────────────
|
|
149
|
+
// For users who install all three suites. Extends the two-suite COMBINED_PROMPTS
|
|
150
|
+
// "pick by intent" pattern with a third branch per field.
|
|
151
|
+
export const ALL_THREE_PROMPTS = {
|
|
152
|
+
reviewTemplate: REVIEW_HEADER +
|
|
153
|
+
'Three complementary review paths are available — pick by intent:\n' +
|
|
154
|
+
'- `/review` (gstack Staff Engineer) for tactical bug-hunting that finds issues passing CI but blowing up in production. Auto-fixes the obvious ones.\n' +
|
|
155
|
+
'- `superpowers:requesting-code-review` for principles-level critique — silent failures, test-design soundness, surface-area discipline.\n' +
|
|
156
|
+
"- `ecc:code-review` (or a Task-dispatched `ecc:code-reviewer` subagent) for ECC's structured multi-agent review pass.\n" +
|
|
157
|
+
'You can run more than one on the same diff if the change is large. If none is available, fall back to the manual checklist below.\n\n' +
|
|
158
|
+
REVIEW_BODY,
|
|
159
|
+
autoLoopReviewGate: 'Code review gate — BEFORE marking the task done, pick the appropriate review path (three are installed):\n' +
|
|
160
|
+
'- Default to `/review` (gstack Staff Engineer) for tactical code-level bugs and auto-fixes.\n' +
|
|
161
|
+
'- Use `superpowers:requesting-code-review` instead when the task introduces tests, refactors, or design decisions worth a principles-level critique.\n' +
|
|
162
|
+
"- Use a Task-dispatched `ecc:code-reviewer` subagent (or the `ecc:quality-gate` skill) when you want ECC's structured multi-agent pass.\n\n" +
|
|
163
|
+
'Brief the chosen reviewer with: what you just implemented, the task title, and the commit SHA (via `git rev-parse HEAD`). Ask specifically whether the change matches the task scope, whether edge cases are handled, and whether the commit is clean. If the reviewer auto-fixes minor issues, accept the fixes via an amend or fix-up commit, then re-run step 3 checks.',
|
|
164
|
+
autoLoopGroomingIntro: GROOMING_INTRO_ALL,
|
|
165
|
+
qaPromptTemplate: `QA pass for workspace "{{workspace_name}}" in project {{project_name}}.
|
|
166
|
+
|
|
167
|
+
Branch: {{branch_name}}
|
|
168
|
+
Staging URL: {{staging_url}}
|
|
169
|
+
|
|
170
|
+
Pick the right tool for the situation. gstack covers interactive QA, ECC covers structured/automated QA, superpowers covers low-level browser control as a fallback.
|
|
171
|
+
|
|
172
|
+
gstack QA toolkit (preferred for interactive QA):
|
|
173
|
+
- \`/browse\` — Headless navigation: URL → interactions → screenshots. Use for quick dogfooding a flow or sanity-checking a PR.
|
|
174
|
+
- \`/qa {{staging_url}}\` — Systematic QA with automatic bug fixing. Three tiers (invoke as \`/qa Quick\`, \`/qa Standard\`, or \`/qa Exhaustive\`):
|
|
175
|
+
- **Quick** — critical and high-severity bugs only.
|
|
176
|
+
- **Standard** — adds medium-severity bugs.
|
|
177
|
+
- **Exhaustive** — adds cosmetic issues.
|
|
178
|
+
- \`/qa-only {{staging_url}}\` — Same methodology as \`/qa\` but report only, no code changes.
|
|
179
|
+
- \`/design-review\` — Visual audit (consistency, spacing, hierarchy, AI slop). Commits atomic fixes with before/after screenshots.
|
|
180
|
+
|
|
181
|
+
ECC toolkit:
|
|
182
|
+
- \`ecc:browser-qa\` skill — structured browser-driven QA pass.
|
|
183
|
+
- Task-dispatched \`ecc:e2e-runner\` subagent — generates, maintains, and runs E2E tests; manages flaky-test quarantine and artifact capture.
|
|
184
|
+
|
|
185
|
+
Superpowers alternative (low-level browser control):
|
|
186
|
+
- \`superpowers-chrome:browsing\` — Direct Chrome DevTools Protocol control over an existing Chrome session: multi-tab management, form automation, content extraction. Use when you need fine-grained control beyond what \`/browse\` exposes.
|
|
187
|
+
|
|
188
|
+
For reproducible regression coverage that runs on every PR, prefer **Cypress** specs in \`test/cypress/\` instead of any interactive QA skill. Reserve the tools above for exploration, dogfooding, and one-shot visual debugging.`,
|
|
189
|
+
brainstormingInstruction: 'Brainstorm using all three suites — each plays to its strength:\n' +
|
|
190
|
+
'1. Early product framing: prefer gstack `/office-hours` for product-shaped work (six forcing questions + design doc), or `ecc:plan` (or `subagent_type: "ecc:planner"`) for ECC-style spec expansion (features, sprints, evaluation criteria). Fall back to `superpowers:brainstorming` for purely infra/refactor work where neither product lens applies.\n' +
|
|
191
|
+
'2. Plan construction — pick whichever fits the work better:\n' +
|
|
192
|
+
' - gstack `/autoplan` for the chained CEO/design/eng/DX pipeline (auto-detects which apply).\n' +
|
|
193
|
+
' - `superpowers:writing-plans` for a TDD-shaped multi-step plan that maps cleanly onto subagent dispatch.\n' +
|
|
194
|
+
' - `ecc:tdd-workflow` when the project follows ECC conventions and you want its test-first shaping.\n' +
|
|
195
|
+
'3. For debugging during exploration, prefer `/investigate` (gstack — root-cause methodology), `superpowers:systematic-debugging`, or a Task-dispatched `ecc:architect` subagent — whichever you reach for first.\n' +
|
|
196
|
+
'4. Plan review gate (MANDATORY before announcing brainstorming is done): once the plan is ready, it MUST pass the gstack plan-review skills before you proceed.\n' +
|
|
197
|
+
' - If you built the plan via `/autoplan`, you have ALREADY passed the chained reviews — skip this step.\n' +
|
|
198
|
+
' - Otherwise (plan came from `superpowers:writing-plans`, `ecc:tdd-workflow`, or any other path), run `/autoplan` now on the existing plan to chain the reviews, OR invoke the relevant ones individually: `/plan-ceo-review` (scope challenge), `/plan-eng-review` (architecture / edge cases / tests), `/plan-design-review` (UI / AI slop, when there is UI scope), `/plan-devex-review` (when the work has developer-facing surface).\n' +
|
|
199
|
+
' - Apply any changes the reviews recommend before moving on.\n' +
|
|
200
|
+
'5. Wait for explicit user approval on the final reviewed plan before announcing brainstorming is done.',
|
|
201
|
+
};
|
|
135
202
|
/**
|
|
136
203
|
* Resolve the suite prompts to use right now, given the global `skillSuite`
|
|
137
204
|
* and the four user-editable `custom*` fields (only consulted in `custom` mode).
|
|
@@ -142,8 +209,12 @@ export function getSuitePrompts(suite, overrides) {
|
|
|
142
209
|
return SUPERPOWERS_PROMPTS;
|
|
143
210
|
if (suite === 'gstack')
|
|
144
211
|
return GSTACK_PROMPTS;
|
|
212
|
+
if (suite === 'ecc')
|
|
213
|
+
return ECC_PROMPTS;
|
|
145
214
|
if (suite === 'superpowers+gstack')
|
|
146
215
|
return COMBINED_PROMPTS;
|
|
216
|
+
if (suite === 'superpowers+gstack+ecc')
|
|
217
|
+
return ALL_THREE_PROMPTS;
|
|
147
218
|
// custom mode: per-field fallback to AGNOSTIC when the override is missing/blank
|
|
148
219
|
const pick = (k) => {
|
|
149
220
|
const value = overrides[k];
|
|
@@ -109,16 +109,18 @@ export function handleConnection(ws) {
|
|
|
109
109
|
export function emit(workspaceId, type, payload, sessionId) {
|
|
110
110
|
const id = nanoid();
|
|
111
111
|
const createdAt = new Date().toISOString();
|
|
112
|
+
let replayable = false;
|
|
112
113
|
// Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
|
|
113
114
|
try {
|
|
114
115
|
const db = getDb();
|
|
115
116
|
db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
|
|
117
|
+
replayable = true;
|
|
116
118
|
}
|
|
117
119
|
catch (err) {
|
|
118
120
|
console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
|
|
119
121
|
}
|
|
120
122
|
// Build the event object to send
|
|
121
|
-
const event = { id, workspaceId, type, payload, sessionId, createdAt };
|
|
123
|
+
const event = { id, workspaceId, type, payload, sessionId, createdAt, replayable };
|
|
122
124
|
const message = JSON.stringify(event);
|
|
123
125
|
// Broadcast to subscribed clients. Wrap `.send` in try/catch so a dropped
|
|
124
126
|
// client doesn't throw and abort delivery to the remaining subscribers.
|
|
@@ -145,7 +147,7 @@ export function emit(workspaceId, type, payload, sessionId) {
|
|
|
145
147
|
export function emitEphemeral(workspaceId, type, payload) {
|
|
146
148
|
const id = nanoid();
|
|
147
149
|
const createdAt = new Date().toISOString();
|
|
148
|
-
const event = { id, workspaceId, type, payload, createdAt };
|
|
150
|
+
const event = { id, workspaceId, type, payload, createdAt, replayable: false };
|
|
149
151
|
const message = JSON.stringify(event);
|
|
150
152
|
let sendErrorLogged = false;
|
|
151
153
|
for (const [ws, subs] of clients) {
|
|
@@ -188,12 +190,14 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
188
190
|
// up. This keeps first-paint fast on long-lived workspaces with tens of
|
|
189
191
|
// thousands of events without ever deleting anything from the DB.
|
|
190
192
|
const INITIAL_WINDOW = 300;
|
|
193
|
+
let mode = 'snapshot';
|
|
191
194
|
if (lastEventId) {
|
|
192
195
|
// Resume path: replay every event strictly after the cursor (delta
|
|
193
196
|
// since last seen). If the cursor is stale/unknown, fall back to the
|
|
194
197
|
// recent window rather than streaming the entire history.
|
|
195
198
|
const lastRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId);
|
|
196
199
|
if (lastRow) {
|
|
200
|
+
mode = 'delta';
|
|
197
201
|
rows = db
|
|
198
202
|
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) AND rowid > ? ORDER BY rowid ASC`)
|
|
199
203
|
.all(...resolvedIds, lastRow.rowid);
|
|
@@ -232,9 +236,10 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
232
236
|
payload: parsedPayload,
|
|
233
237
|
sessionId: row.session_id ?? undefined,
|
|
234
238
|
createdAt: row.created_at,
|
|
239
|
+
replayable: true,
|
|
235
240
|
};
|
|
236
241
|
});
|
|
237
|
-
ws.send(JSON.stringify({ type: 'sync:response', payload: { events } }));
|
|
242
|
+
ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode } }));
|
|
238
243
|
}
|
|
239
244
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
240
245
|
/**
|
|
@@ -165,20 +165,29 @@ export function updateWorkspaceStatus(id, status) {
|
|
|
165
165
|
return getWorkspace(id);
|
|
166
166
|
}
|
|
167
167
|
const WORKSPACE_NAME_MAX_LENGTH = 200;
|
|
168
|
-
|
|
169
|
-
export function updateWorkspaceName(id, name) {
|
|
170
|
-
// Strip control characters (incl. newlines) and collapse whitespace
|
|
168
|
+
function sanitizeWorkspaceName(name) {
|
|
171
169
|
const noControl = Array.from(name, (c) => {
|
|
172
170
|
const code = c.charCodeAt(0);
|
|
173
171
|
return code < 32 || code === 127 ? ' ' : c;
|
|
174
172
|
}).join('');
|
|
175
173
|
const sanitized = noControl.replace(/\s+/g, ' ').trim();
|
|
176
|
-
if (!sanitized)
|
|
174
|
+
if (!sanitized)
|
|
177
175
|
throw new Error('Workspace name cannot be empty');
|
|
178
|
-
}
|
|
179
176
|
if (sanitized.length > WORKSPACE_NAME_MAX_LENGTH) {
|
|
180
177
|
throw new Error(`Workspace name cannot exceed ${WORKSPACE_NAME_MAX_LENGTH} characters`);
|
|
181
178
|
}
|
|
179
|
+
return sanitized;
|
|
180
|
+
}
|
|
181
|
+
function normalizeWorkspaceDescription(description) {
|
|
182
|
+
const trimmed = description == null ? null : description.trim();
|
|
183
|
+
if (trimmed !== null && trimmed.length > 200) {
|
|
184
|
+
throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
|
|
185
|
+
}
|
|
186
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
187
|
+
}
|
|
188
|
+
/** Update a workspace's display name. */
|
|
189
|
+
export function updateWorkspaceName(id, name) {
|
|
190
|
+
const sanitized = sanitizeWorkspaceName(name);
|
|
182
191
|
const db = getDb();
|
|
183
192
|
const now = new Date().toISOString();
|
|
184
193
|
const result = db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(sanitized, now, id);
|
|
@@ -311,11 +320,7 @@ export function updateAgentPermissionMode(id, mode) {
|
|
|
311
320
|
* does not exist.
|
|
312
321
|
*/
|
|
313
322
|
export function updateWorkspaceDescription(id, description) {
|
|
314
|
-
const
|
|
315
|
-
if (trimmed !== null && trimmed.length > 200) {
|
|
316
|
-
throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
|
|
317
|
-
}
|
|
318
|
-
const stored = trimmed && trimmed.length > 0 ? trimmed : null;
|
|
323
|
+
const stored = normalizeWorkspaceDescription(description);
|
|
319
324
|
const db = getDb();
|
|
320
325
|
const result = db
|
|
321
326
|
.prepare('UPDATE workspaces SET description = ?, updated_at = ? WHERE id = ?')
|
|
@@ -326,6 +331,72 @@ export function updateWorkspaceDescription(id, description) {
|
|
|
326
331
|
emitEphemeral(id, 'workspace:description-updated', { description: stored });
|
|
327
332
|
return getWorkspace(id);
|
|
328
333
|
}
|
|
334
|
+
/** Validate and persist a multi-field API update with one atomic SQL statement. */
|
|
335
|
+
export function updateWorkspaceFields(id, fields) {
|
|
336
|
+
const workspace = getWorkspace(id);
|
|
337
|
+
if (!workspace)
|
|
338
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
339
|
+
const assignments = [];
|
|
340
|
+
const values = [];
|
|
341
|
+
if (fields.model !== undefined) {
|
|
342
|
+
if (typeof fields.model !== 'string' || !fields.model.trim())
|
|
343
|
+
throw new Error('model must be a non-empty string');
|
|
344
|
+
assignments.push('model = ?');
|
|
345
|
+
values.push(fields.model.trim());
|
|
346
|
+
}
|
|
347
|
+
if (fields.reasoningEffort !== undefined) {
|
|
348
|
+
if (typeof fields.reasoningEffort !== 'string' || !fields.reasoningEffort.trim()) {
|
|
349
|
+
throw new Error('reasoningEffort must be a non-empty string');
|
|
350
|
+
}
|
|
351
|
+
assignments.push('reasoning_effort = ?');
|
|
352
|
+
values.push(fields.reasoningEffort.trim());
|
|
353
|
+
}
|
|
354
|
+
if (fields.agentPermissionMode !== undefined) {
|
|
355
|
+
if (!['plan', 'bypass', 'strict', 'interactive'].includes(fields.agentPermissionMode)) {
|
|
356
|
+
throw new Error('Invalid agentPermissionMode');
|
|
357
|
+
}
|
|
358
|
+
const legacyMode = fields.agentPermissionMode === 'plan' ? 'plan' : 'auto-accept';
|
|
359
|
+
const legacyProfile = fields.agentPermissionMode === 'plan' ? 'bypass' : fields.agentPermissionMode;
|
|
360
|
+
assignments.push('agent_permission_mode = ?', 'permission_mode = ?', 'permission_profile = ?');
|
|
361
|
+
values.push(fields.agentPermissionMode, legacyMode, legacyProfile);
|
|
362
|
+
}
|
|
363
|
+
if (fields.status !== undefined) {
|
|
364
|
+
const allowed = VALID_TRANSITIONS[workspace.status];
|
|
365
|
+
if (!allowed || (fields.status !== workspace.status && !allowed.includes(fields.status))) {
|
|
366
|
+
throw new Error(`Invalid status transition from '${workspace.status}' to '${fields.status}'. Allowed: ${allowed?.join(', ') ?? ''}`);
|
|
367
|
+
}
|
|
368
|
+
assignments.push('status = ?');
|
|
369
|
+
values.push(fields.status);
|
|
370
|
+
}
|
|
371
|
+
if (fields.name !== undefined) {
|
|
372
|
+
if (typeof fields.name !== 'string')
|
|
373
|
+
throw new Error('name must be a string');
|
|
374
|
+
assignments.push('name = ?');
|
|
375
|
+
values.push(sanitizeWorkspaceName(fields.name));
|
|
376
|
+
}
|
|
377
|
+
const hasDescription = Object.hasOwn(fields, 'description');
|
|
378
|
+
let storedDescription;
|
|
379
|
+
if (hasDescription) {
|
|
380
|
+
if (fields.description !== null && typeof fields.description !== 'string') {
|
|
381
|
+
throw new Error('description must be a string or null');
|
|
382
|
+
}
|
|
383
|
+
storedDescription = normalizeWorkspaceDescription(fields.description ?? null);
|
|
384
|
+
assignments.push('description = ?');
|
|
385
|
+
values.push(storedDescription);
|
|
386
|
+
}
|
|
387
|
+
if (assignments.length === 0)
|
|
388
|
+
return workspace;
|
|
389
|
+
assignments.push('updated_at = ?');
|
|
390
|
+
values.push(new Date().toISOString(), id);
|
|
391
|
+
const result = getDb()
|
|
392
|
+
.prepare(`UPDATE workspaces SET ${assignments.join(', ')} WHERE id = ?`)
|
|
393
|
+
.run(...values);
|
|
394
|
+
if (result.changes === 0)
|
|
395
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
396
|
+
if (hasDescription)
|
|
397
|
+
emitEphemeral(id, 'workspace:description-updated', { description: storedDescription ?? null });
|
|
398
|
+
return getWorkspace(id);
|
|
399
|
+
}
|
|
329
400
|
/**
|
|
330
401
|
* Update a workspace's agent-side description (≤ 200 chars after trim).
|
|
331
402
|
* Empty string (after trim) or `null` clears the column.
|