@loicngr/kobo 1.11.44 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +57 -4
  2. package/CHANGELOG.md +13 -0
  3. package/CODE_OF_CONDUCT.md +16 -0
  4. package/CONFIGURATION.md +1968 -0
  5. package/CONTRIBUTING.md +83 -0
  6. package/DESIGN.md +296 -0
  7. package/README.md +48 -8
  8. package/SECURITY.md +54 -0
  9. package/THIRD_PARTY_NOTICES.md +20 -0
  10. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  11. package/dist/mcp-server/kobo-tasks-server.js +31 -5
  12. package/dist/server/db/migrations.js +37 -0
  13. package/dist/server/db/schema.js +6 -1
  14. package/dist/server/db/session-handoff-schema.js +24 -0
  15. package/dist/server/index.js +8 -0
  16. package/dist/server/routes/documents.js +1 -1
  17. package/dist/server/routes/environment.js +18 -0
  18. package/dist/server/routes/health.js +17 -2
  19. package/dist/server/routes/integrations.js +29 -0
  20. package/dist/server/routes/settings.js +5 -0
  21. package/dist/server/routes/sounds.js +60 -0
  22. package/dist/server/routes/workspaces.js +118 -20
  23. package/dist/server/services/agent/engines/claude-code/engine.js +15 -0
  24. package/dist/server/services/agent/engines/codex/engine.js +1 -0
  25. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  26. package/dist/server/services/agent/engines/codex/spawn.js +2 -2
  27. package/dist/server/services/agent/integration-mcp.js +49 -0
  28. package/dist/server/services/agent/orchestrator.js +68 -11
  29. package/dist/server/services/auto-loop-service.js +45 -25
  30. package/dist/server/services/custom-sound-service.js +0 -0
  31. package/dist/server/services/engine-handoff-service.js +5 -5
  32. package/dist/server/services/environment-check-service.js +125 -0
  33. package/dist/server/services/initial-prompt-template-service.js +8 -21
  34. package/dist/server/services/integration-config-service.js +65 -0
  35. package/dist/server/services/legacy-public-prompts.js +112 -0
  36. package/dist/server/services/notion-service.js +25 -61
  37. package/dist/server/services/sentry-service.js +17 -12
  38. package/dist/server/services/session-activity-service.js +17 -0
  39. package/dist/server/services/session-handoff-runtime.js +26 -0
  40. package/dist/server/services/session-handoff-service.js +479 -0
  41. package/dist/server/services/settings-defaults.js +5 -0
  42. package/dist/server/services/settings-service.js +112 -23
  43. package/dist/server/services/skill-suite-prompts.js +29 -22
  44. package/dist/server/services/templates-service.js +31 -8
  45. package/dist/server/services/terminal-service.js +16 -0
  46. package/dist/server/services/wakeup-service.js +15 -5
  47. package/dist/server/services/workspace-service.js +30 -6
  48. package/dist/server/services/worktree-service.js +3 -1
  49. package/dist/server/utils/git-repo-lock.js +7 -3
  50. package/dist/server/utils/mcp-client.js +26 -12
  51. package/dist/server/utils/mcp-process.js +25 -0
  52. package/dist/server/utils/node-pty-spawn-helper.js +31 -0
  53. package/dist/server/utils/paths.js +7 -0
  54. package/dist/server/utils/workspace-lifecycle-guard.js +35 -20
  55. package/dist/shared/auto-loop-prompts.js +6 -2
  56. package/dist/shared/automatic-admission.js +1 -0
  57. package/dist/shared/document-roots.js +2 -0
  58. package/dist/shared/environment.js +1 -0
  59. package/dist/shared/notification-assets.js +73 -0
  60. package/dist/shared/session-handoff.js +4 -0
  61. package/dist/shared/skill-suite-prompts.js +6 -3
  62. package/dist/shared/workflow-policy.js +25 -0
  63. package/docs/getting-started.md +47 -0
  64. package/docs/troubleshooting.md +49 -0
  65. package/package.json +19 -7
  66. package/src/client/dist/pwa/assets/ActivityFeed-DRgetl1M.js +8 -0
  67. package/src/client/dist/pwa/assets/{ChangelogPage-DSKCvpr2.js → ChangelogPage-BJdICFvz.js} +1 -1
  68. package/src/client/dist/pwa/assets/{ComparisonPanel-DDxcsxyC.js → ComparisonPanel-2GLd8KIy.js} +1 -1
  69. package/src/client/dist/pwa/assets/CreatePage-B9AdNJZj.js +2 -0
  70. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +1 -0
  71. package/src/client/dist/pwa/assets/DashboardPage-CzVOvjSP.js +1 -0
  72. package/src/client/dist/pwa/assets/DiffViewer-X25vuf83.js +8 -0
  73. package/src/client/dist/pwa/assets/{DocumentsPanel-DfEoI_f7.js → DocumentsPanel-BastjrEm.js} +1 -1
  74. package/src/client/dist/pwa/assets/{DrawerToggleButton-D85PwhKb.js → DrawerToggleButton-DbipGmBL.js} +1 -1
  75. package/src/client/dist/pwa/assets/{GitPanel-BFCnyhE2.css → GitPanel-C4wOcZV2.css} +1 -1
  76. package/src/client/dist/pwa/assets/GitPanel-CnW4iVao.js +3 -0
  77. package/src/client/dist/pwa/assets/{HealthPage-CaLCpejR.js → HealthPage-aieNQly-.js} +1 -1
  78. package/src/client/dist/pwa/assets/MainLayout-Cc4XzCAy.css +1 -0
  79. package/src/client/dist/pwa/assets/MainLayout-DqBbE8o4.js +2 -0
  80. package/src/client/dist/pwa/assets/{QBtnDropdown-qJWGcZpC.js → QBtnDropdown-BWy-rh45.js} +1 -1
  81. package/src/client/dist/pwa/assets/QExpansionItem-DfmdBGuW.js +1 -0
  82. package/src/client/dist/pwa/assets/QSelect-BfqaWakg.js +1 -0
  83. package/src/client/dist/pwa/assets/{QTooltip-cDK5AOQ5.js → QTooltip-Cy0T0vk2.js} +1 -1
  84. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +1 -0
  85. package/src/client/dist/pwa/assets/SchedulePanel-YQPObRR3.js +1 -0
  86. package/src/client/dist/pwa/assets/{SearchPage-BO-J4aNc.js → SearchPage-BoBjmItc.js} +1 -1
  87. package/src/client/dist/pwa/assets/SettingsPage-0GtDcXRC.js +9 -0
  88. package/src/client/dist/pwa/assets/{SettingsPage-W6N01tGz.css → SettingsPage-BYdLXJEJ.css} +1 -1
  89. package/src/client/dist/pwa/assets/SplitWorkspacePage-4wFh65Lh.js +1 -0
  90. package/src/client/dist/pwa/assets/{TerminalPanel-DitOCad1.js → TerminalPanel-b70x_jqH.js} +9 -9
  91. package/src/client/dist/pwa/assets/{TouchPan-DvPAhZsa.js → TouchPan-CcTVcxwd.js} +1 -1
  92. package/src/client/dist/pwa/assets/{TourReplayButton-CJYdH3Jf.js → TourReplayButton-CpEMnQS8.js} +1 -1
  93. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-C5lTHTp2.js +1 -0
  94. package/src/client/dist/pwa/assets/{WorkspacePage-DAVo-E4p.css → WorkspacePage--5HsnMQ1.css} +1 -1
  95. package/src/client/dist/pwa/assets/WorkspacePage-D_V9gNON.js +4 -0
  96. package/src/client/dist/pwa/assets/{browser-BsB6cFv4.js → browser-LBbxVwWf.js} +1 -1
  97. package/src/client/dist/pwa/assets/build-path-tree-DyvtWxb0.js +1 -0
  98. package/src/client/dist/pwa/assets/custom-sounds-BpeLZUDW.js +1 -0
  99. package/src/client/dist/pwa/assets/de-v6i2HlE2.js +17 -0
  100. package/src/client/dist/pwa/assets/{documents-SCd3w8xV.js → documents-Bjx8lz-h.js} +1 -1
  101. package/src/client/dist/pwa/assets/{editor.api-BUdM7I6y.js → editor.api-BC3PbJkx.js} +1 -1
  102. package/src/client/dist/pwa/assets/efforts-BDHAnNUs.js +9 -0
  103. package/src/client/dist/pwa/assets/engineFeatures-hlEiwqt-.js +1 -0
  104. package/src/client/dist/pwa/assets/es-Bc1KgkAT.js +17 -0
  105. package/src/client/dist/pwa/assets/expand-template-DLDl1qPx.js +1 -0
  106. package/src/client/dist/pwa/assets/{format-BjQxf4pe.js → format-Q94deO3I.js} +7 -7
  107. package/src/client/dist/pwa/assets/{formatters-BejhmNTV.js → formatters-CWxRHuUu.js} +1 -1
  108. package/src/client/dist/pwa/assets/fr-ByYPK5vr.js +17 -0
  109. package/src/client/dist/pwa/assets/{freemarker2-5d5GhyRp.js → freemarker2-MPhk0eTh.js} +1 -1
  110. package/src/client/dist/pwa/assets/{handlebars-DaafEsDK.js → handlebars-C60ru5gR.js} +1 -1
  111. package/src/client/dist/pwa/assets/{html-CwHBDd2A.js → html-ClzchR9j.js} +1 -1
  112. package/src/client/dist/pwa/assets/i18n-Sy42tm-V.js +1 -0
  113. package/src/client/dist/pwa/assets/index-CqZ7sDcM.js +20 -0
  114. package/src/client/dist/pwa/assets/it-BYF_88z6.js +17 -0
  115. package/src/client/dist/pwa/assets/{javascript-DSNIEEH5.js → javascript-C43UJmMx.js} +1 -1
  116. package/src/client/dist/pwa/assets/{liquid-Dma1UpA1.js → liquid-CYTrvTKF.js} +1 -1
  117. package/src/client/dist/pwa/assets/{mdx-D8rqEAws.js → mdx-CxsXOvkQ.js} +1 -1
  118. package/src/client/dist/pwa/assets/{monaco-contributions-2E7O2zoi.js → monaco-contributions-Cl4-iVpK.js} +1 -1
  119. package/src/client/dist/pwa/assets/monaco.contribution-DkjLUmyA.js +2 -0
  120. package/src/client/dist/pwa/assets/network-auth-CXQn2wO7.js +1 -0
  121. package/src/client/dist/pwa/assets/notify-theme-BrueBr2j.js +1 -0
  122. package/src/client/dist/pwa/assets/{pending-diff-open-CeiVIFZn.js → pending-diff-open-B4POItbR.js} +1 -1
  123. package/src/client/dist/pwa/assets/permissionModes-B-ciuUbz.js +36 -0
  124. package/src/client/dist/pwa/assets/{project-color-BCp9SWoI.js → project-color-CM3hflLH.js} +1 -1
  125. package/src/client/dist/pwa/assets/{python-f-9NqRlI.js → python-s2IMpsoV.js} +1 -1
  126. package/src/client/dist/pwa/assets/{razor-DsCmoX0h.js → razor-DVNXiVBh.js} +1 -1
  127. package/src/client/dist/pwa/assets/render-chat-markdown-TU6CDfD8.js +69 -0
  128. package/src/client/dist/pwa/assets/{typescript-cEHg4L10.js → typescript-BYT_8ABE.js} +1 -1
  129. package/src/client/dist/pwa/assets/use-slide-transition-CiaDm-tR.js +1 -0
  130. package/src/client/dist/pwa/assets/use-tours-CNe8qb1q.js +2 -0
  131. package/src/client/dist/pwa/assets/workspace-templates-tBxXE52A.js +1 -0
  132. package/src/client/dist/pwa/assets/{xml-Plt-Y7we.js → xml-BIG-fmxk.js} +1 -1
  133. package/src/client/dist/pwa/assets/{yaml-ScEmaAeN.js → yaml-D7gu2CQh.js} +1 -1
  134. package/src/client/dist/pwa/index.html +1 -1
  135. package/src/client/dist/pwa/sounds/neutral.wav +0 -0
  136. package/src/client/dist/pwa/sounds/ready.wav +0 -0
  137. package/src/client/dist/pwa/sw.js +1 -1
  138. package/src/mcp-server/README.md +8 -1
  139. package/src/mcp-server/kobo-tasks-handlers.ts +4 -1
  140. package/src/mcp-server/kobo-tasks-server.ts +38 -5
  141. package/src/client/dist/pwa/assets/ActivityFeed-D_y4aikp.js +0 -8
  142. package/src/client/dist/pwa/assets/CreatePage-DKVEknbL.js +0 -2
  143. package/src/client/dist/pwa/assets/CreatePage-TY9g3hZb.css +0 -1
  144. package/src/client/dist/pwa/assets/DashboardPage-Dg2s9nnv.js +0 -1
  145. package/src/client/dist/pwa/assets/DiffViewer-BSzNScIr.js +0 -8
  146. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +0 -3
  147. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +0 -2
  148. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +0 -1
  149. package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +0 -1
  150. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +0 -1
  151. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +0 -1
  152. package/src/client/dist/pwa/assets/SchedulePanel-LvHiZ6ZH.js +0 -1
  153. package/src/client/dist/pwa/assets/SettingsPage-Aw89fRSq.js +0 -9
  154. package/src/client/dist/pwa/assets/SplitWorkspacePage-BIEceNwH.js +0 -1
  155. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CY-9z9nw.js +0 -1
  156. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +0 -4
  157. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +0 -1
  158. package/src/client/dist/pwa/assets/de-Mu7biceO.js +0 -17
  159. package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +0 -9
  160. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +0 -1
  161. package/src/client/dist/pwa/assets/es-BY5IfNI2.js +0 -17
  162. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +0 -1
  163. package/src/client/dist/pwa/assets/fouet-ahh-DdxrUXRj.mp3 +0 -0
  164. package/src/client/dist/pwa/assets/fr-BMXCzFCM.js +0 -17
  165. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +0 -1
  166. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +0 -20
  167. package/src/client/dist/pwa/assets/it-ByPZQK_K.js +0 -17
  168. package/src/client/dist/pwa/assets/monaco.contribution-DqOgo72G.js +0 -2
  169. package/src/client/dist/pwa/assets/network-auth-DNmhJTZn.js +0 -1
  170. package/src/client/dist/pwa/assets/notify-theme-B6Hiwvwb.js +0 -1
  171. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +0 -36
  172. package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +0 -67
  173. package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +0 -2
  174. package/src/client/dist/pwa/assets/workspace-templates-DUWDLMIX.js +0 -1
  175. package/src/client/dist/pwa/sounds/7eme-compagnie-03.mp3 +0 -0
  176. package/src/client/dist/pwa/sounds/aller-ftg.mp3 +0 -0
  177. package/src/client/dist/pwa/sounds/arrete-de-mentir.mp3 +0 -0
  178. package/src/client/dist/pwa/sounds/arretez-les-messages.mp3 +0 -0
  179. package/src/client/dist/pwa/sounds/bah-alors-on-est-nul.mp3 +0 -0
  180. package/src/client/dist/pwa/sounds/basic-notification.mp3 +0 -0
  181. package/src/client/dist/pwa/sounds/ca_va_peter.mp3 +0 -0
  182. package/src/client/dist/pwa/sounds/dry-fart.mp3 +0 -0
  183. package/src/client/dist/pwa/sounds/faaah.mp3 +0 -0
  184. package/src/client/dist/pwa/sounds/for-shure.mp3 +0 -0
  185. package/src/client/dist/pwa/sounds/gta-v-death.mp3 +0 -0
  186. package/src/client/dist/pwa/sounds/hey.mp3 +0 -0
  187. package/src/client/dist/pwa/sounds/mais-laisse-moi-dormir-zebi.mp3 +0 -0
  188. package/src/client/dist/pwa/sounds/nan-tu-degages.mp3 +0 -0
  189. package/src/client/dist/pwa/sounds/nan-wallah-pardon.mp3 +0 -0
  190. package/src/client/dist/pwa/sounds/ouais-cest-greg.mp3 +0 -0
  191. package/src/client/dist/pwa/sounds/pas-ca-zinedine.mp3 +0 -0
  192. package/src/client/dist/pwa/sounds/ta-gueule.mp3 +0 -0
  193. package/src/client/dist/pwa/sounds/travail_termine.mp3 +0 -0
  194. package/src/client/dist/pwa/sounds/tu-vas-la-fermer.mp3 +0 -0
  195. package/src/client/dist/pwa/sounds/warcraft-3-humain-travail.mp3 +0 -0
  196. package/src/client/dist/pwa/sounds/yaaa.mp3 +0 -0
@@ -45,11 +45,11 @@ const failedRetries = new Map();
45
45
  /** Keep a claimed wakeup durable while the session is still unavailable. */
46
46
  function defer(workspaceId, row) {
47
47
  const targetAt = new Date(Date.now() + ACTIVE_SESSION_RETRY_MS).toISOString();
48
- getDb().prepare('UPDATE pending_wakeups SET target_at = ? WHERE workspace_id = ?').run(targetAt, workspaceId);
48
+ getDb().prepare('UPDATE pending_wakeups SET retry_at = ? WHERE workspace_id = ?').run(targetAt, workspaceId);
49
49
  const timeout = setTimeout(() => fire(workspaceId), ACTIVE_SESSION_RETRY_MS);
50
50
  timeout.unref?.();
51
51
  timers.set(workspaceId, timeout);
52
- emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt, reason: row.reason ?? undefined });
52
+ emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt: row.target_at, reason: row.reason ?? undefined });
53
53
  }
54
54
  /** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
55
55
  export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
@@ -76,6 +76,14 @@ export function schedule(workspaceId, delaySeconds, prompt, reason, agentSession
76
76
  console.error('[wakeup-service] schedule failed:', err);
77
77
  }
78
78
  }
79
+ /** Move a source conversation's pending check only once its handoff succeeds. */
80
+ export function transferSession(workspaceId, sourceSessionId, targetSessionId) {
81
+ if (!sourceSessionId)
82
+ return;
83
+ getDb()
84
+ .prepare('UPDATE pending_wakeups SET agent_session_id = ? WHERE workspace_id = ? AND agent_session_id = ?')
85
+ .run(targetSessionId, workspaceId, sourceSessionId);
86
+ }
79
87
  /** Cancel any pending wakeup for the workspace. Idempotent. */
80
88
  export function cancel(workspaceId, reason) {
81
89
  try {
@@ -116,7 +124,7 @@ export function getPending(workspaceId) {
116
124
  export function isWakeupScheduled(workspaceId) {
117
125
  return getPending(workspaceId) !== null;
118
126
  }
119
- /** Re-register timers for rows persisted across restart. Skips stale entries. */
127
+ /** Re-register persisted timers. Previously deferred work survives downtime; only never-attempted stale wakeups expire. */
120
128
  export function rehydrate() {
121
129
  suspendForShutdown();
122
130
  suspended = false;
@@ -126,14 +134,16 @@ export function rehydrate() {
126
134
  const now = Date.now();
127
135
  for (const row of rows) {
128
136
  try {
129
- const target = new Date(row.target_at).getTime();
137
+ const target = new Date(row.retry_at ?? row.target_at).getTime();
130
138
  const delay = target - now;
131
139
  if (delay > 0) {
132
140
  const timeout = setTimeout(() => fire(row.workspace_id), delay);
133
141
  timeout.unref?.();
134
142
  timers.set(row.workspace_id, timeout);
135
143
  }
136
- else if (-delay <= STALE_WAKEUP_GRACE_MS) {
144
+ else if (row.retry_at != null || -delay <= STALE_WAKEUP_GRACE_MS) {
145
+ // Admission deferred this wakeup after its deadline. Downtime does not
146
+ // revoke the pending instruction; retry through the same admission gate.
137
147
  const timeout = setTimeout(() => fire(row.workspace_id), 0);
138
148
  timeout.unref?.();
139
149
  timers.set(row.workspace_id, timeout);
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { parseTaskVerification } from '../../shared/task-verification.js';
3
+ import { isWorkflowPolicy, LEGACY_WORKFLOW_POLICY, resolveWorkflowPolicy, } from '../../shared/workflow-policy.js';
3
4
  import { getDb } from '../db/index.js';
4
5
  import { isValidBranchName } from '../utils/git-ops.js';
5
6
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
@@ -7,6 +8,7 @@ import * as orchestrator from './agent/orchestrator.js';
7
8
  import * as autoLoopService from './auto-loop-service.js';
8
9
  import * as cronService from './cron-service.js';
9
10
  import * as quotaBackoffService from './quota-backoff-service.js';
11
+ import { SESSION_RECENCY_ORDER } from './session-activity-service.js';
10
12
  import { createTaskRecord, deleteTaskRecord, updateTaskRecord } from './task-mutations.js';
11
13
  import * as wakeupService from './wakeup-service.js';
12
14
  import { emitEphemeral } from './websocket-service.js';
@@ -53,7 +55,17 @@ function coerceAgentPermissionMode(raw) {
53
55
  return 'bypass';
54
56
  }
55
57
  function mapWorkspace(row) {
58
+ let workflowPolicy = { ...LEGACY_WORKFLOW_POLICY };
59
+ if (row.workflow_policy) {
60
+ try {
61
+ workflowPolicy = resolveWorkflowPolicy(JSON.parse(row.workflow_policy));
62
+ }
63
+ catch {
64
+ workflowPolicy = resolveWorkflowPolicy();
65
+ }
66
+ }
56
67
  return {
68
+ workflowPolicy,
57
69
  id: row.id,
58
70
  name: row.name,
59
71
  projectPath: row.project_path,
@@ -127,6 +139,9 @@ export function createWorkspace(data) {
127
139
  const id = nanoid();
128
140
  const computedWorktreePath = data.worktreePath ?? resolveWorkspaceWorktreePath(data.projectPath, data.workingBranch, data.worktreesPath);
129
141
  const owned = data.worktreeOwned ?? true;
142
+ if (data.workflowPolicy !== undefined && !isWorkflowPolicy(data.workflowPolicy))
143
+ throw new Error('Invalid workflowPolicy');
144
+ const workflowPolicy = resolveWorkflowPolicy(data.workflowPolicy);
130
145
  // Mirror the unified mode into the legacy columns so older readers (in-flight
131
146
  // requests during deploy, external scripts) still see a sane value.
132
147
  const unifiedMode = data.agentPermissionMode ?? 'bypass';
@@ -136,12 +151,12 @@ export function createWorkspace(data) {
136
151
  INSERT INTO workspaces (
137
152
  id, name, project_path, source_branch, working_branch, status,
138
153
  notion_url, notion_page_id, sentry_url, pr_url, worktree_path, worktree_owned,
139
- model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, comparison_id, created_at, updated_at
140
- ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
154
+ model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, comparison_id, created_at, updated_at, workflow_policy
155
+ ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
141
156
  `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-8', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code',
142
157
  // Empty string is not a group: it would silently gather every workspace
143
158
  // created with a blank id into one comparison.
144
- data.comparisonId?.trim() ? data.comparisonId : null, now, now);
159
+ data.comparisonId?.trim() ? data.comparisonId : null, now, now, JSON.stringify(workflowPolicy));
145
160
  return getWorkspace(id);
146
161
  }
147
162
  /** Fetch a single workspace by ID, or null if not found. */
@@ -161,7 +176,8 @@ export function listComparisonMembers(comparisonId) {
161
176
  if (!comparisonId.trim())
162
177
  return [];
163
178
  const rows = getDb()
164
- .prepare('SELECT * FROM workspaces WHERE comparison_id = ? ORDER BY created_at, id')
179
+ // rowid breaks created_at ties in insertion order; the nanoid id is random.
180
+ .prepare('SELECT * FROM workspaces WHERE comparison_id = ? ORDER BY created_at, rowid')
165
181
  .all(comparisonId);
166
182
  return rows.map(mapWorkspace);
167
183
  }
@@ -388,6 +404,12 @@ export function updateWorkspaceFields(id, fields) {
388
404
  throw new Error(`Workspace '${id}' not found`);
389
405
  const assignments = [];
390
406
  const values = [];
407
+ if (fields.workflowPolicy !== undefined) {
408
+ if (!isWorkflowPolicy(fields.workflowPolicy))
409
+ throw new Error('Invalid workflowPolicy');
410
+ assignments.push('workflow_policy = ?');
411
+ values.push(JSON.stringify(resolveWorkflowPolicy(workspace.workflowPolicy, fields.workflowPolicy)));
412
+ }
391
413
  if (fields.model !== undefined) {
392
414
  if (typeof fields.model !== 'string' || !fields.model.trim())
393
415
  throw new Error('model must be a non-empty string');
@@ -776,6 +798,7 @@ function mapSession(row) {
776
798
  model: row.model,
777
799
  startedAt: row.started_at,
778
800
  endedAt: row.ended_at,
801
+ activationOrder: row.activation_order,
779
802
  name: row.name,
780
803
  };
781
804
  }
@@ -799,7 +822,8 @@ export function getLatestSession(workspaceId) {
799
822
  * Return the "active" session for tagging events like push/pull/open-pr traces
800
823
  * or resumed chat messages. Skips idle sessions (never started) and prefers a
801
824
  * running session, falling back to the last used session (any non-idle status).
802
- * A resumed conversation keeps its started_at, but updates ended_at on stop.
825
+ * Starts and handoff rollbacks persist activation order independently of execution dates.
826
+ * Legacy sessions fall back to their last start/end time.
803
827
  */
804
828
  export function getActiveSession(workspaceId) {
805
829
  const db = getDb();
@@ -811,7 +835,7 @@ export function getActiveSession(workspaceId) {
811
835
  return mapSession(running);
812
836
  // Otherwise the last used non-idle session (completed, error, quota, etc.)
813
837
  const latestNonIdle = db
814
- .prepare("SELECT * FROM agent_sessions WHERE workspace_id = ? AND status != 'idle' ORDER BY MAX(started_at, COALESCE(ended_at, started_at)) DESC, started_at DESC LIMIT 1")
838
+ .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND status != 'idle' AND activation_order >= 0 ORDER BY ${SESSION_RECENCY_ORDER} LIMIT 1`)
815
839
  .get(workspaceId);
816
840
  return latestNonIdle ? mapSession(latestNonIdle) : null;
817
841
  }
@@ -450,8 +450,10 @@ export async function restoreWorktreeCheckoutUnlocked(input) {
450
450
  };
451
451
  }
452
452
  const registrations = await restoreRegistrations(projectPath);
453
+ // The destination is missing, so resolve its parent: git registers resolved paths.
454
+ const canonicalTarget = path.join(await canonicalize(path.dirname(worktreePath)), path.basename(worktreePath));
453
455
  // Do not globally prune: another missing checkout may still need its registration.
454
- if (registrations.some((entry) => path.resolve(entry.path) === worktreePath)) {
456
+ if (registrations.some((entry) => path.resolve(entry.path) === canonicalTarget)) {
455
457
  throw new WorktreeCheckoutError('path-conflict', 'Git still registers the missing destination. Repair that worktree registration before retrying.');
456
458
  }
457
459
  if (registrations.some((entry) => entry.branch === `refs/heads/${workingBranch}`)) {
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { promisify } from 'node:util';
4
5
  /**
@@ -14,7 +15,10 @@ const execFileAsync = promisify(execFile);
14
15
  // out of order. This queue covers resolution only, never the locked operation.
15
16
  let registrations = Promise.resolve();
16
17
  /**
17
- * Absolute path of the git directory shared by every worktree of a repository.
18
+ * Absolute, symlink-resolved path of the git directory shared by every worktree
19
+ * of a repository. Git reports worktree paths resolved, so an unresolved key
20
+ * would split one repository into two locks when it is reached through a link
21
+ * (e.g. macOS temp dirs under /var -> /private/var).
18
22
  *
19
23
  * Falls back to the resolved input path when git cannot answer (not a
20
24
  * repository, git missing): a wrong key only costs parallelism, never
@@ -22,14 +26,14 @@ let registrations = Promise.resolve();
22
26
  */
23
27
  export async function resolveGitCommonDir(repoPath) {
24
28
  try {
25
- const { stdout } = await execFileAsync('git', ['rev-parse', '--git-common-dir'], {
29
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
26
30
  cwd: repoPath,
27
31
  encoding: 'utf-8',
28
32
  timeout: 10_000,
29
33
  killSignal: 'SIGKILL',
30
34
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
35
  });
32
- return path.resolve(repoPath, stdout.replace(/\n$/, ''));
36
+ return await realpath(stdout.replace(/\n$/, ''));
33
37
  }
34
38
  catch {
35
39
  return path.resolve(repoPath);
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
+ import { stopMcpProcess } from './mcp-process.js';
3
4
  import { getPackageVersion } from './paths.js';
4
5
  function getClaudeConfigPath() {
5
6
  const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
@@ -52,7 +53,7 @@ const nextRpcId = (() => {
52
53
  /**
53
54
  * Spawn an MCP server process given explicit command, args, and env.
54
55
  * The caller is responsible for constructing the full env (including auth headers).
55
- * stderr is consumed silently; set DEBUG_MCP_STDERR=1 to print it.
56
+ * stderr content is never logged: integrations may print credentials. Debug mode reports byte counts only.
56
57
  */
57
58
  export function spawnMcpProcess(command, args, env) {
58
59
  const mcpProcess = spawn(command, args, {
@@ -67,12 +68,12 @@ export function spawnMcpProcess(command, args, env) {
67
68
  // without any listener crashes the whole Node process (Unhandled 'error').
68
69
  // Callers may attach their own listener for typed handling — listeners are
69
70
  // additive on EventEmitter so this default never blocks them.
70
- mcpProcess.on('error', (err) => {
71
- console.error(`[mcp] spawn '${command}' failed: ${err.message}`);
71
+ mcpProcess.on('error', () => {
72
+ console.error('[mcp] Failed to start configured server; check executable and connection settings');
72
73
  });
73
74
  mcpProcess.stderr?.on('data', (data) => {
74
75
  if (process.env.DEBUG_MCP_STDERR) {
75
- console.error('[mcp stderr]', data.toString());
76
+ console.error('[mcp] stderr received (content withheld)', data.length);
76
77
  }
77
78
  });
78
79
  return mcpProcess;
@@ -101,7 +102,7 @@ export async function initializeMcp(mcpProcess) {
101
102
  const initTimeoutMs = Number(process.env.KOBO_MCP_INIT_TIMEOUT_MS) || 30_000;
102
103
  const timeout = setTimeout(() => {
103
104
  mcpProcess.stdout?.removeListener('data', onData);
104
- mcpProcess.kill();
105
+ stopMcpProcess(mcpProcess);
105
106
  reject(new Error(`initializeMcp timed out after ${initTimeoutMs}ms`));
106
107
  }, initTimeoutMs);
107
108
  const onData = (chunk) => {
@@ -117,6 +118,10 @@ export async function initializeMcp(mcpProcess) {
117
118
  if (parsed.id === id) {
118
119
  clearTimeout(timeout);
119
120
  mcpProcess.stdout?.removeListener('data', onData);
121
+ if (parsed.error) {
122
+ reject(new Error('MCP initialization failed; check integration configuration'));
123
+ return;
124
+ }
120
125
  const initialized = JSON.stringify({
121
126
  jsonrpc: '2.0',
122
127
  method: 'notifications/initialized',
@@ -130,10 +135,10 @@ export async function initializeMcp(mcpProcess) {
130
135
  }
131
136
  }
132
137
  };
133
- const onError = (err) => {
138
+ const onError = () => {
134
139
  clearTimeout(timeout);
135
140
  mcpProcess.stdout?.removeListener('data', onData);
136
- reject(err);
141
+ reject(new Error('MCP transport failed'));
137
142
  };
138
143
  mcpProcess.stdout.on('data', onData);
139
144
  mcpProcess.stdout.once('error', onError);
@@ -158,7 +163,7 @@ export async function callMcpTool(mcpProcess, toolName, args) {
158
163
  const timeout = setTimeout(() => {
159
164
  mcpProcess.stdout?.removeListener('data', onData);
160
165
  mcpProcess.stdout?.removeListener('error', onError);
161
- mcpProcess.kill();
166
+ stopMcpProcess(mcpProcess);
162
167
  reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
163
168
  }, 30_000);
164
169
  const onData = (chunk) => {
@@ -176,10 +181,17 @@ export async function callMcpTool(mcpProcess, toolName, args) {
176
181
  mcpProcess.stdout?.removeListener('data', onData);
177
182
  mcpProcess.stdout?.removeListener('error', onError);
178
183
  if (parsed.error) {
179
- reject(new Error(`MCP tool '${toolName}' error: ${parsed.error.message} (code: ${parsed.error.code})`));
184
+ reject(new Error(`MCP tool failed (code: ${Number.isInteger(parsed.error.code) ? parsed.error.code : 'unknown'})`));
180
185
  }
181
186
  else {
182
- resolve(parsed.result);
187
+ if (parsed.result &&
188
+ typeof parsed.result === 'object' &&
189
+ 'isError' in parsed.result &&
190
+ parsed.result.isError === true) {
191
+ reject(new Error('MCP tool reported a failure; check integration credentials and access'));
192
+ }
193
+ else
194
+ resolve(parsed.result);
183
195
  }
184
196
  }
185
197
  }
@@ -188,10 +200,10 @@ export async function callMcpTool(mcpProcess, toolName, args) {
188
200
  }
189
201
  }
190
202
  };
191
- const onError = (err) => {
203
+ const onError = () => {
192
204
  clearTimeout(timeout);
193
205
  mcpProcess.stdout?.removeListener('data', onData);
194
- reject(err);
206
+ reject(new Error('MCP transport failed'));
195
207
  };
196
208
  mcpProcess.stdout.on('data', onData);
197
209
  mcpProcess.stdout.once('error', onError);
@@ -206,6 +218,8 @@ export async function callMcpTool(mcpProcess, toolName, args) {
206
218
  export function unwrapMcpResult(result) {
207
219
  if (result && typeof result === 'object') {
208
220
  const obj = result;
221
+ if (obj.isError === true)
222
+ throw new Error('MCP tool reported a failure; check integration credentials and access');
209
223
  if (Array.isArray(obj.content)) {
210
224
  const first = obj.content[0];
211
225
  if (first?.type === 'text' && first.text) {
@@ -0,0 +1,25 @@
1
+ /** MCP commands run in their own process group, including wrapper descendants. */
2
+ export function stopMcpProcess(child) {
3
+ child.stdin?.end();
4
+ const pid = child.pid;
5
+ const signal = (value) => {
6
+ try {
7
+ if (pid && process.platform !== 'win32')
8
+ process.kill(-pid, value);
9
+ else
10
+ child.kill(value);
11
+ }
12
+ catch {
13
+ try {
14
+ child.kill(value);
15
+ }
16
+ catch {
17
+ /* Already gone. */
18
+ }
19
+ }
20
+ };
21
+ signal('SIGTERM');
22
+ // Keep the group cleanup even when a wrapper exits before its descendants.
23
+ const force = setTimeout(() => signal('SIGKILL'), 1000);
24
+ force.unref();
25
+ }
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ function nodePtyRoot() {
5
+ return path.dirname(createRequire(import.meta.url).resolve('node-pty/package.json'));
6
+ }
7
+ /**
8
+ * node-pty 1.1.0 publishes its macOS `spawn-helper` prebuilds without the
9
+ * execute bit, so every spawn fails with `posix_spawnp failed` (a blank
10
+ * terminal in Kōbō). Restore the bit at runtime rather than in an install
11
+ * script, which `--ignore-scripts` installs would skip.
12
+ *
13
+ * Returns the helper it made executable, or `null` when there is nothing to do.
14
+ * Throws when the helper cannot be changed (e.g. a root-owned global install).
15
+ */
16
+ export function ensureSpawnHelperExecutable({ platform = process.platform, arch = process.arch, root, } = {}) {
17
+ if (platform !== 'darwin')
18
+ return null;
19
+ const helper = path.join(root ?? nodePtyRoot(), 'prebuilds', `darwin-${arch}`, 'spawn-helper');
20
+ let mode;
21
+ try {
22
+ mode = fs.statSync(helper).mode;
23
+ }
24
+ catch {
25
+ return null; // Built from source: node-gyp output is already executable.
26
+ }
27
+ if ((mode & 0o111) !== 0)
28
+ return null;
29
+ fs.chmodSync(helper, (mode & 0o777) | 0o111);
30
+ return helper;
31
+ }
@@ -101,6 +101,13 @@ export function getTemplatesPath() {
101
101
  export function getWorkspaceTemplatesPath() {
102
102
  return path.join(getKoboHome(), 'workspace-templates.json');
103
103
  }
104
+ /**
105
+ * Directory holding the user's imported notification sounds and their manifest.
106
+ * Separate from the bundled tones shipped inside the SPA under `/sounds`.
107
+ */
108
+ export function getCustomSoundsDir() {
109
+ return path.join(getKoboHome(), 'sounds');
110
+ }
104
111
  /**
105
112
  * Absolute path to the compiled MCP server entry (shipped in the published
106
113
  * package as dist/mcp-server/kobo-tasks-server.js). Returns null if not
@@ -1,4 +1,4 @@
1
- const busyWorkspaces = new Set();
1
+ const busyWorkspaces = new Map();
2
2
  const deferred = new Map();
3
3
  export class WorkspaceLifecycleBusyError extends Error {
4
4
  code = 'workspace-busy';
@@ -9,10 +9,40 @@ export class WorkspaceLifecycleBusyError extends Error {
9
9
  export function isWorkspaceLifecycleBusy(id) {
10
10
  return busyWorkspaces.has(id);
11
11
  }
12
- export function assertWorkspaceLifecycleAvailable(id) {
13
- if (busyWorkspaces.has(id))
12
+ export function assertWorkspaceLifecycleAvailable(id, owner) {
13
+ const reservation = busyWorkspaces.get(id);
14
+ if (owner ? reservation?.owner !== owner : !!reservation)
14
15
  throw new WorkspaceLifecycleBusyError();
15
16
  }
17
+ export function workspaceLifecycleReason(id) {
18
+ return busyWorkspaces.get(id)?.reason;
19
+ }
20
+ export function reserveWorkspaceLifecycle(id, reason) {
21
+ assertWorkspaceLifecycleAvailable(id);
22
+ const owner = Symbol(id);
23
+ busyWorkspaces.set(id, { owner, reason });
24
+ return {
25
+ owner,
26
+ release() {
27
+ if (busyWorkspaces.get(id)?.owner !== owner)
28
+ return;
29
+ busyWorkspaces.delete(id);
30
+ const callbacks = deferred.get(id);
31
+ deferred.delete(id);
32
+ if (callbacks)
33
+ queueMicrotask(() => {
34
+ for (const retry of callbacks) {
35
+ try {
36
+ retry();
37
+ }
38
+ catch (err) {
39
+ console.error('[workspace-lifecycle] deferred operation failed:', err);
40
+ }
41
+ }
42
+ });
43
+ },
44
+ };
45
+ }
16
46
  /** Keep automatic work pending without polling or acquiring the guard recursively. */
17
47
  export function deferUntilWorkspaceAvailable(id, retry) {
18
48
  if (!busyWorkspaces.has(id))
@@ -24,26 +54,11 @@ export function deferUntilWorkspaceAvailable(id, retry) {
24
54
  }
25
55
  /** Exclude competing restore/purge/delete operations, including their awaits. */
26
56
  export async function withWorkspaceLifecycleGuard(id, action) {
27
- if (busyWorkspaces.has(id))
28
- throw new WorkspaceLifecycleBusyError();
29
- busyWorkspaces.add(id);
57
+ const reservation = reserveWorkspaceLifecycle(id);
30
58
  try {
31
59
  return await action();
32
60
  }
33
61
  finally {
34
- busyWorkspaces.delete(id);
35
- const callbacks = deferred.get(id);
36
- deferred.delete(id);
37
- if (callbacks)
38
- queueMicrotask(() => {
39
- for (const retry of callbacks) {
40
- try {
41
- retry();
42
- }
43
- catch (err) {
44
- console.error('[workspace-lifecycle] deferred operation failed:', err);
45
- }
46
- }
47
- });
62
+ reservation.release();
48
63
  }
49
64
  }
@@ -48,7 +48,9 @@ export function buildAutoLoopGroomingSteps(e2e, finalization) {
48
48
  ];
49
49
  let nextNum = 4;
50
50
  if (e2e.framework) {
51
- const skillHint = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task. ` : '';
51
+ const skillHint = e2e.skill
52
+ ? `Use the \`${e2e.skill}\` skill for this task if available; otherwise use the existing project test tools directly. `
53
+ : '';
52
54
  const promptHint = e2e.prompt ? `Additional guidance: ${e2e.prompt}` : '';
53
55
  steps.push(`${nextNum}. **E2E review**: walk the task list and identify which tasks produce user-visible behavior (UI flows, form submissions, page renders, etc.). For each one that warrants regression coverage, INSERT a follow-up sub-task with title prefixed \`[E2E] \` describing the test to write. Use \`after_task_id\` to insert it directly after the parent task; \`list_tasks\` exposes \`sort_order\` so you can verify ordering. Skip tasks that don't produce user-visible behavior (refactors, infra, internal services) and briefly justify your choices in chat. The project uses \`${e2e.framework}\`. ${skillHint}${promptHint}`.trim());
54
56
  nextNum++;
@@ -84,7 +86,9 @@ Read kobo__list_tasks again. If any work or criterion remains open, or any requi
84
86
  export function buildE2eIterationBlock(e2e) {
85
87
  if (!e2e.framework)
86
88
  return '';
87
- const skillLine = e2e.skill ? `Use the \`${e2e.skill}\` skill for this task.\n` : '';
89
+ const skillLine = e2e.skill
90
+ ? `Use the \`${e2e.skill}\` skill for this task if available; otherwise use the existing project test tools directly.\n`
91
+ : '';
88
92
  const promptLine = e2e.prompt ? `Additional guidance: ${e2e.prompt}\n` : '';
89
93
  return `
90
94
  This is an **E2E regression test** task.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ /** Shared allowlist for the HTTP browser and workspace MCP document tools. */
2
+ export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts', '.ai/handoffs'];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ /** Only original, reproducible sounds are included in the public distribution. */
2
+ export const PUBLIC_NOTIFICATION_SOUNDS = ['neutral.wav', 'ready.wav'];
3
+ export const DEFAULT_PUBLIC_NOTIFICATION_SOUND = PUBLIC_NOTIFICATION_SOUNDS[0];
4
+ /**
5
+ * User-imported sounds live in `KOBO_HOME/sounds/` and are referenced as
6
+ * `custom:<id>`. The id is generated server-side, so a reference never carries
7
+ * a user-supplied filename and can never become a path segment on its own.
8
+ */
9
+ export const CUSTOM_SOUND_PREFIX = 'custom:';
10
+ export const CUSTOM_SOUND_ID_LENGTH = 12;
11
+ const CUSTOM_SOUND_ID_PATTERN = new RegExp(`^[A-Za-z0-9_-]{${CUSTOM_SOUND_ID_LENGTH}}$`);
12
+ export const CUSTOM_SOUND_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.webm'];
13
+ /** Formats every browser Kōbō targets can decode. */
14
+ export const CUSTOM_SOUND_CONTENT_TYPES = {
15
+ '.wav': 'audio/wav',
16
+ '.mp3': 'audio/mpeg',
17
+ '.ogg': 'audio/ogg',
18
+ '.webm': 'audio/webm',
19
+ };
20
+ export const CUSTOM_SOUND_ACCEPT = [...Object.values(CUSTOM_SOUND_CONTENT_TYPES), ...CUSTOM_SOUND_EXTENSIONS].join(',');
21
+ /** A notification is short: the largest removed bundled sound was 207 KB. */
22
+ export const MAX_CUSTOM_SOUND_BYTES = 2 * 1024 * 1024;
23
+ export const MAX_CUSTOM_SOUNDS = 20;
24
+ export function customSoundReference(id) {
25
+ return `${CUSTOM_SOUND_PREFIX}${id}`;
26
+ }
27
+ export function isCustomNotificationSound(value) {
28
+ return (typeof value === 'string' &&
29
+ value.startsWith(CUSTOM_SOUND_PREFIX) &&
30
+ CUSTOM_SOUND_ID_PATTERN.test(value.slice(CUSTOM_SOUND_PREFIX.length)));
31
+ }
32
+ /** Returns the bare id of a well-formed reference, or an empty string. */
33
+ export function customSoundId(value) {
34
+ return isCustomNotificationSound(value) ? value.slice(CUSTOM_SOUND_PREFIX.length) : '';
35
+ }
36
+ /** Browsers label audio MIME types inconsistently, so the extension decides. */
37
+ export function customSoundExtension(name) {
38
+ const extension = /\.[^.\\/]+$/.exec(name)?.[0]?.toLowerCase() ?? '';
39
+ return CUSTOM_SOUND_EXTENSIONS.includes(extension) ? extension : null;
40
+ }
41
+ /**
42
+ * A custom reference is kept on its shape alone: settings migrations run before
43
+ * the catalogue is readable, and both the client and the file route already fall
44
+ * back to the bundled tone when the file is gone.
45
+ */
46
+ export function normalizePublicNotificationSound(value, allowSentinel = true) {
47
+ if (allowSentinel && (value === 'inherit' || value === 'none'))
48
+ return value;
49
+ if (isCustomNotificationSound(value))
50
+ return value;
51
+ return typeof value === 'string' && PUBLIC_NOTIFICATION_SOUNDS.includes(value)
52
+ ? value
53
+ : DEFAULT_PUBLIC_NOTIFICATION_SOUND;
54
+ }
55
+ export const NOTIFICATION_SOUND_KEYS = [
56
+ 'audioNotificationSound',
57
+ 'audioQuestionSound',
58
+ 'audioWorkspaceCreatedSound',
59
+ 'audioAgentErrorSound',
60
+ 'audioPrCiFailedSound',
61
+ 'audioPrCiRecoveredSound',
62
+ 'audioPrChangesRequestedSound',
63
+ 'audioPrApprovedSound',
64
+ 'audioPrMergeConflictSound',
65
+ 'audioPrReadyToMergeSound',
66
+ 'audioPrMergedSound',
67
+ ];
68
+ export function normalizePublicSounds(settings) {
69
+ for (const key of NOTIFICATION_SOUND_KEYS) {
70
+ if (key in settings)
71
+ settings[key] = normalizePublicNotificationSound(settings[key], key !== 'audioNotificationSound');
72
+ }
73
+ }
@@ -0,0 +1,4 @@
1
+ export function isHandoffPending(handoff) {
2
+ return !!handoff && handoff.state !== 'completed' && handoff.state !== 'cancelled';
3
+ }
4
+ export const HANDOFF_REPORT_MAX_CHARS = 24_000;
@@ -1,6 +1,7 @@
1
1
  // src/shared/skill-suite-prompts.ts
2
2
  export function isValidSkillSuite(value) {
3
- return (value === 'superpowers' ||
3
+ return (value === 'standard' ||
4
+ value === 'superpowers' ||
4
5
  value === 'gstack' ||
5
6
  value === 'ecc' ||
6
7
  value === 'superpowers+gstack' ||
@@ -24,7 +25,7 @@ const REVIEW_BODY = `## Scope
24
25
 
25
26
  Review ALL changes — both committed and uncommitted in the working tree:
26
27
  - \`git diff {{base_commit}}..HEAD\` — committed changes on this branch
27
- - \`git status\` and \`git diff\` — uncommitted changes (staged + unstaged)
28
+ - \`git status\` and \`git diff\` / \`git diff --cached\` — unstaged / staged changes; inspect untracked files too
28
29
 
29
30
  ## Diff summary
30
31
  {{diff_stats}}
@@ -47,7 +48,7 @@ If no review skill is available, structure your reply as:
47
48
  export const AGNOSTIC_REVIEW_TEMPLATE = REVIEW_HEADER +
48
49
  'If a code-review skill is available in this environment, invoke it to drive this audit. Otherwise follow the manual checklist below.\n\n' +
49
50
  REVIEW_BODY;
50
- export const AGNOSTIC_AUTO_LOOP_REVIEW_GATE = 'Code review gate — BEFORE marking the task done, run whichever code-review skill is configured in this environment. Brief it 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 no review skill is available, do a manual self-review against the same criteria.';
51
+ export const AGNOSTIC_AUTO_LOOP_REVIEW_GATE = 'Code review gate — BEFORE marking the task done, run whichever code-review skill is configured in this environment. Brief it 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 all committed and uncommitted changes are correct. Include staged, unstaged and untracked files. If no review skill is available, do a manual self-review against the same criteria.';
51
52
  export const AGNOSTIC_AUTO_LOOP_GROOMING_INTRO = 'You are preparing this workspace for Kōbō auto-loop mode. This is a GROOMING session only — DO NOT implement anything, DO NOT write or edit code, DO NOT run tests or builds, DO NOT invoke any implementation, planning, or release skill (your environment may have several). Your ONLY job is to curate the Kōbō task list via MCP tools.';
52
53
  export const AGNOSTIC_QA_PROMPT_TEMPLATE = 'QA pass for workspace "{{workspace_name}}" in project {{project_name}}.\n\nBranch: {{branch_name}}\nStaging URL: {{staging_url}}\n\nIf a QA skill is configured in this environment, invoke it on {{staging_url}}. Otherwise, manually exercise the staging URL against the workspace\'s acceptance criteria and report findings.';
53
54
  export const AGNOSTIC_BRAINSTORMING_INSTRUCTION = 'Brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear, user-approved plan, save it as a plan file in the project.';
@@ -65,6 +66,8 @@ export const GROOMING_INTRO_ALL = 'You are preparing this workspace for Kōbō a
65
66
  * user-provided override is used (or AGNOSTIC if the override is empty/blank).
66
67
  */
67
68
  export function getGroomingIntro(suite, customOverride) {
69
+ if (suite === 'standard')
70
+ return AGNOSTIC_AUTO_LOOP_GROOMING_INTRO;
68
71
  if (suite === 'custom') {
69
72
  const trimmed = (customOverride ?? '').trim();
70
73
  return trimmed || AGNOSTIC_AUTO_LOOP_GROOMING_INTRO;