@worca/app 1.3.0 → 1.4.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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -1,6 +1,6 @@
1
1
  // The per-run memory MOUNT (agent-memory-design.md §4.1, amendment A1) and the
2
2
  // hash-baselined SYNC-BACK (§5). Pure over injected paths: `root` is the store
3
- // (memory-store.mjs' layout), `mount` is `<runCwd>/.claude/rules/worca` (memoryMountPath). No DB, no
3
+ // (memory-store.mjs' layout), `mount` is the WRITABLE copy `<pipeline.dir>/memory` (memoryWorkPath); the read-only rules copy is memoryRulesPath(cwd). No DB, no
4
4
  // orchestrator state, no settings reads — the harness passes caps and paths.
5
5
  import { mkdir, rm, cp, readFile, writeFile, stat, rename, unlink, readdir } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
@@ -11,16 +11,25 @@ import {
11
11
  import { MEMORY_DEFRAG_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
12
12
 
13
13
  /**
14
- * Where the mount lives INSIDE a run's cwd (native-rules revision, 2026-09-13): Claude Code
15
- * discovers `<cwd>/.claude/rules/**` recursively, honours `paths:` frontmatter (comma-separated
16
- * string included) and hands the files to Task sub-agents — probed on claude 2.1.270 from a git
17
- * worktree cwd and from a non-git run root. `worca/` namespaces the mount away from a project's
18
- * own committed rules. Forward slashes on purpose: the string is also the git pathspec of the
19
- * §8.8 exclusion set (`:(exclude).claude/rules/worca`).
14
+ * Two copies of the store per run (memory-write-split design, 2026-09-17):
15
+ * - the READ-ONLY rules copy INSIDE a run's cwd — Claude Code discovers `<cwd>/.claude/rules/**`
16
+ * recursively, honours `paths:` frontmatter and hands the files to Task sub-agents (probed on
17
+ * claude 2.1.270). `.claude` is one of Claude Code's PROTECTED paths: a Write/Edit there is refused
18
+ * under `-p` whatever the allow rules or hooks say (probed 2.1.273), so nothing is ever written here
19
+ * by an agent; worca refreshes it from the store after every sync that changed the store.
20
+ * - the WRITABLE copy = the sync-back mount, `<pipeline.dir>/memory/`, outside every checkout and
21
+ * cwd (no git hygiene, no tracked guard, survives a pause for the resume sync), passed to every
22
+ * spawn as `--add-dir` so acceptEdits auto-approves edits there by the documented rule.
23
+ * `worca/` namespaces the rules copy away from a project's own committed rules. Forward slashes on
24
+ * purpose: MEMORY_RULES_REL is also the git pathspec of the §8.8 exclusion set.
20
25
  */
21
26
  export const MEMORY_RULES_REL = '.claude/rules/worca';
22
- /** `<cwd>/.claude/rules/worca` with the platform separator. */
23
- export function memoryMountPath(cwd) { return join(cwd, ...MEMORY_RULES_REL.split('/')); }
27
+ /** `<cwd>/.claude/rules/worca` with the platform separator — the read-only copy. */
28
+ export function memoryRulesPath(cwd) { return join(cwd, ...MEMORY_RULES_REL.split('/')); }
29
+ /** The writable copy's name under the pipeline dir. */
30
+ export const MEMORY_WORK_REL = 'memory';
31
+ /** `<pipelineDir>/memory` — the writable copy = the sync-back mount. */
32
+ export function memoryWorkPath(pipelineDir) { return join(pipelineDir, MEMORY_WORK_REL); }
24
33
  /**
25
34
  * The §8.8 injected-path record of the mount. `kind: 'memory'` is EXCLUDED from every commit,
26
35
  * intent-to-add staging and result diff (run-harness `_excludePathspecs`), REMOVED at teardown
@@ -179,9 +188,11 @@ const isHash = (v) => typeof v === 'string' && !v.startsWith('rejected:');
179
188
  * `rejected:invalid` and for P1's two-part `rejected:<mountHash>` (the store never vouched). */
180
189
  const storeHashOf = (v) => (isHash(v) ? v : ((typeof v === 'string' && v.split(':')[2]) || null));
181
190
 
182
- /** Totals over a list of Change entries — one shape for results.json and the History detail. */
191
+ /** Totals over a list of Change entries — one shape for results.json, the History detail and Ask's
192
+ * get_run. `failed` = writes the agent attempted that never reached the mount (a refused tool call);
193
+ * a record written before the write split has no `failed` array and counts as 0. */
183
194
  export function memoryTotals(changes) {
184
- const totals = { added: 0, modified: 0, deleted: 0, rejected: 0 };
195
+ const totals = { added: 0, modified: 0, deleted: 0, rejected: 0, failed: 0 };
185
196
  for (const c of Array.isArray(changes) ? changes : []) for (const k of Object.keys(totals)) totals[k] += Array.isArray(c[k]) ? c[k].length : 0;
186
197
  return totals;
187
198
  }
@@ -214,9 +214,11 @@ function syncFor(slug, prefs, pending) {
214
214
  export async function readScope(scope, { refresh = false, defer = false } = {}) {
215
215
  const sources = [];
216
216
  let meta;
217
+ let rateProjectDir = null;
217
218
  if (scope.kind === 'project') {
218
219
  const p = (await listProjects()).find((x) => x.key === scope.id);
219
220
  if (!p) throw Object.assign(new Error(`unknown project ${scope.id}`), { code: 'NOT_FOUND' });
221
+ rateProjectDir = p.path;
220
222
  const sink = await resolveProjectSink(p.path);
221
223
  if (!sink.ok) {
222
224
  const code = sink.reason === 'delegate-invalid' ? 'DELEGATE_INVALID' : 'NOT_ENABLED';
@@ -235,6 +237,7 @@ export async function readScope(scope, { refresh = false, defer = false } = {})
235
237
  sources.push({ slug: prefs.slug, projectDir: path, keep: (r) => matchesWorkspace(r.target, ws) });
236
238
  }
237
239
  if (!sources.length) throw Object.assign(new Error(`no member of ${ws.name} records team metrics`), { code: 'NOT_ENABLED' });
240
+ rateProjectDir = ws.metricsProject || null;
238
241
  const homePrefs = ws.metricsProject ? readTeamMetricsPrefs(projectKey(ws.metricsProject)) : null;
239
242
  meta = { kind: 'workspace', id: ws.id, name: ws.name, home: homePrefs?.slug ?? null, sources: sources.map((s) => s.slug) };
240
243
  }
@@ -270,7 +273,7 @@ export async function readScope(scope, { refresh = false, defer = false } = {})
270
273
  }
271
274
  sync.push({ ...syncFor(src.slug, readTeamMetricsPrefs(projectKey(src.projectDir)), (await listOutbox(src.slug)).length), fetchedAt: r.fetchedAt });
272
275
  }
273
- return { scope: meta, records, stats, sinks: sources.map((s) => s.slug), sync, refresh: refreshInfo, fetchError };
276
+ return { scope: meta, records, stats, sinks: sources.map((s) => s.slug), sync, refresh: refreshInfo, fetchError, rateProjectDir };
274
277
  }
275
278
 
276
279
  /** Scope list + Projects/Workspaces status in one call (Scope select, Projects cells, ws cards, Stats hint). */
@@ -14,6 +14,7 @@ import {
14
14
  projectSlug, gitUserName, resolveProjectSink, resolveWorkspaceSink, writeOutbox, scheduleFlush as realScheduleFlush,
15
15
  } from './sync.mjs';
16
16
  import { writeRunLedger } from './ledger.mjs';
17
+ import { readPolicyState } from '../policy/state.mjs';
17
18
 
18
19
  export const RECORD_VERSION = 1;
19
20
  export const TEXT_MAX = 200;
@@ -22,11 +23,13 @@ export const WORCA_VERSION = createRequire(import.meta.url)('../../../package.js
22
23
  /** Serialised key order of a v1 record — diffs stay readable (§4.4). */
23
24
  export const RECORD_FIELDS = Object.freeze([
24
25
  'v', 'id', 'worca', 'recordedAt',
25
- 'startedAt', 'endedAt', 'wallMs', 'activeMs',
26
+ 'startedAt', 'endedAt', 'wallMs', 'activeMs', 'pausedMs',
26
27
  'result', 'failure',
27
28
  'workflow', 'target', 'title', 'source',
28
29
  'cost', 'agents', 'steps', 'cycles', 'interventions',
29
30
  'pr', 'git', 'actor',
31
+ // `policy` (team-policy design §10) is an OPTIONAL trailing key: present only on runs that
32
+ // saw a policy, so policy-less records keep exactly this key list. Readers ignore it.
30
33
  ]);
31
34
 
32
35
  const RESULT_OF = Object.freeze({ done: 'done', error: 'failed', stopped: 'stopped' });
@@ -58,7 +61,7 @@ const num = (v) => (Number.isFinite(v) ? v : null);
58
61
  const unique = (arr) => [...new Set(arr.filter((x) => typeof x === 'string' && x))];
59
62
 
60
63
  // failure-policy REASON codes of a cost-cap pause (src/core/failure-policy.mjs).
61
- const BUDGET_PAUSE = new Set(['cost_pipeline', 'cost_total']);
64
+ const BUDGET_PAUSE = new Set(['cost_pipeline', 'cost_total', 'cost_pipeline_policy', 'cost_total_policy']);
62
65
 
63
66
  /**
64
67
  * failure (§4.4). Cost caps and setup failures PAUSE the run, so the last pause reason is
@@ -112,6 +115,19 @@ function buildSource(src) {
112
115
  };
113
116
  }
114
117
 
118
+ /** money-saved design §7: hours from the run, byPhase from the agent steps (UI phase, like cost.byPhase). */
119
+ function buildHuman(agentSteps, humanHours) {
120
+ const hours = Number.isFinite(humanHours) ? Math.round(humanHours * 100) / 100 : 0;
121
+ if (hours <= 0) return null;
122
+ const byPhase = {};
123
+ for (const s of agentSteps) {
124
+ const h = Number(s?.humanHours);
125
+ if (!s?.phase || !Number.isFinite(h) || h <= 0) continue;
126
+ byPhase[s.phase] = Math.round(((byPhase[s.phase] || 0) + h) * 100) / 100;
127
+ }
128
+ return { hours, byPhase };
129
+ }
130
+
115
131
  function buildCost(steps, totalCostUsd) {
116
132
  const byPhase = {};
117
133
  let sum = 0;
@@ -133,6 +149,26 @@ function maxCyclePerPhase(agentSteps) {
133
149
  return out;
134
150
  }
135
151
 
152
+ const strList = (arr) => unique(Array.isArray(arr) ? arr.filter((x) => typeof x === 'string').map((x) => cleanText(x, 120)) : []);
153
+
154
+ /**
155
+ * `policy` (team-policy design §10): the home the run's policy came from and what the developer
156
+ * did about it. Absent (null) when the run saw no policy. `reason` is free text visible to the
157
+ * team, so it follows the branch's attribution choice: dropped under `attribution: none`.
158
+ */
159
+ function buildPolicy(p, attribution) {
160
+ if (!p || typeof p !== 'object' || !p.home) return null;
161
+ return {
162
+ home: cleanText(p.home, 120),
163
+ sha: typeof p.sha === 'string' && /^[0-9a-f]{7,40}$/i.test(p.sha) ? p.sha.slice(0, 7).toLowerCase() : null,
164
+ overrides: strList(p.overrides),
165
+ exceeded: strList(p.exceeded),
166
+ deviations: strList(p.deviations),
167
+ unattended: p.unattended === true,
168
+ reason: attribution === 'none' ? null : cleanText(redactPaths(p.reason)),
169
+ };
170
+ }
171
+
136
172
  /**
137
173
  * Build a v1 RunRecord from a normalized snapshot (see snapshotFromHarness).
138
174
  * @param {object} snap
@@ -162,6 +198,9 @@ export function buildRunRecord(snap, { attribution = 'git-user', now = new Date(
162
198
  endedAt: isoSec(snap.endedAt) ?? recordedAt,
163
199
  wallMs: Number.isFinite(startMs) && Number.isFinite(endMs) ? Math.max(0, endMs - startMs) : null,
164
200
  activeMs: num(snap.totalActiveMs),
201
+ // Parked (paused, or dead between a crash and its resume): leaves the autonomy denominator.
202
+ // Additive under v1; a record without it parks nothing.
203
+ pausedMs: num(snap.pausedMs) ?? 0,
165
204
  result,
166
205
  failure: buildFailure(snap, result, agentSteps),
167
206
  workflow: snap.workflow
@@ -192,6 +231,9 @@ export function buildRunRecord(snap, { attribution = 'git-user', now = new Date(
192
231
  deletions: num(g.deletions),
193
232
  },
194
233
  actor: attribution === 'none' ? null : cleanText(snap.actor),
234
+ // Present ONLY on runs that saw a policy: records of policy-less runs stay byte-identical to v1.
235
+ ...((p) => (p ? { policy: p } : {}))(buildPolicy(snap.policy, attribution)),
236
+ ...((h) => (h ? { human: h } : {}))(buildHuman(agentSteps, snap.humanHours)),
195
237
  };
196
238
  }
197
239
 
@@ -294,6 +336,7 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
294
336
  endedAt: st.updatedAt,
295
337
  totalActiveMs: st.totalActiveMs,
296
338
  totalCostUsd: st.totalCostUsd,
339
+ humanHours: st.humanHours,
297
340
  steps: withUiPhases(st.steps || [], st.stepper?.graph),
298
341
  subAgents: st.subAgents || [],
299
342
  workflow: {
@@ -322,6 +365,7 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
322
365
  deletions: summary ? summary.linesRemoved ?? null : null,
323
366
  },
324
367
  interventions: { questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: iv.resumes | 0 },
368
+ pausedMs: Number.isFinite(iv.pausedMs) ? iv.pausedMs : 0,
325
369
  // _completePaused stamps iv. A stop/error that lands while a forced pause is still unwinding
326
370
  // never reaches it (pause → stop before the unwind finishes → site B), so fall back to this
327
371
  // instance's live reason; resume() clears both at rehydration (decision 2).
@@ -329,6 +373,10 @@ export async function snapshotFromHarness(harness, { status, error = null } = {}
329
373
  ? { reason: iv.lastPauseReason, detail: iv.lastPauseDetail ?? null }
330
374
  : harness.pauseReason ? { reason: harness.pauseReason, detail: harness.pauseDetail ?? null } : null,
331
375
  actor: await gitUserName(harness.projectDir),
376
+ // The run's policy state (pipelines.policy_state) as the gates and the resume flow left it;
377
+ // `unattended` is the harness's own auto flag, which the record needs even when nothing else
378
+ // was written (a --yes run that stayed under every cap still carries no state row).
379
+ policy: (() => { const p = readPolicyState(runId); return p.home ? { ...p, unattended: p.unattended === true || !!harness.auto } : null; })(),
332
380
  };
333
381
  }
334
382
 
@@ -149,8 +149,9 @@ export async function gitUserName(dir) {
149
149
  }
150
150
 
151
151
  /** `-c` identity args for metrics commits (§4.5: git user, or Worca under attribution:none).
152
- * A name without an email would make `git commit` fail, so both must be set to use the git user. */
153
- async function identityArgs(dir, attribution) {
152
+ * A name without an email would make `git commit` fail, so both must be set to use the git user.
153
+ * Exported for the team-policy branch (policy/sync.mjs), which commits under the same rules. */
154
+ export async function identityArgs(dir, attribution) {
154
155
  if (attribution !== 'none' && await gitUserName(dir)) {
155
156
  const email = await _git(dir, ['config', 'user.email'], { timeoutMs: 10_000 });
156
157
  if (email.ok && email.stdout.trim()) return [];
@@ -446,8 +447,9 @@ async function removeWorktreeDir(dir, projectDir) {
446
447
  if (projectDir) await _git(projectDir, ['worktree', 'prune']);
447
448
  }
448
449
 
449
- /** Build a parentless commit holding `files` ({relPath: content}) in projectDir's object store. */
450
- async function orphanCommit(projectDir, files, message, attribution) {
450
+ /** Build a parentless commit holding `files` ({relPath: content}) in projectDir's object store.
451
+ * Exported for the team-policy branch (policy/sync.mjs): same orphan-branch enable recipe. */
452
+ export async function orphanCommit(projectDir, files, message, attribution) {
451
453
  const gitDir = (await _git(projectDir, ['rev-parse', '--absolute-git-dir'])).stdout.trim();
452
454
  await mkdir(join(metricsRoot(), 'tmp'), { recursive: true });
453
455
  const stage = await mkdtemp(join(metricsRoot(), 'tmp', 'enable-'));
@@ -285,3 +285,152 @@ export function subagentModelIssue(v) {
285
285
  if (v == null || v === '' || isSubagentModelValue(v)) return '';
286
286
  return `unknown sub-agent model ${JSON.stringify(String(v))}`;
287
287
  }
288
+
289
+ // ── model bridge: `upstream` on a catalog entry (model-bridge-design.md §6.1) ──
290
+ // Lives in this zero-import leaf for the same reason `cost` does: the user
291
+ // catalog (settings.mjs) and a plugin manifest (plugin-manifest.mjs) validate
292
+ // the same shape against one rule, and neither may import the other.
293
+ //
294
+ // { provider: 'copilot'|'openai'|'anthropic', api: 'anthropic'|'openai-chat',
295
+ // model: '<upstream id>', baseUrl?, apiKey?, headers?, capabilities? }
296
+ //
297
+ // A bridged entry is dispatched through worca's in-process loopback bridge
298
+ // (src/core/bridge/): resolveModelEnv synthesizes ANTHROPIC_BASE_URL /
299
+ // ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL itself, so those keys — and
300
+ // ANTHROPIC_API_KEY, which would make the CLI prefer a first-party key — may
301
+ // not also appear in the entry's own `env` map.
302
+
303
+ export const UPSTREAM_PROVIDERS = Object.freeze(['copilot', 'openai', 'anthropic']);
304
+ export const UPSTREAM_APIS = Object.freeze(['anthropic', 'openai-chat']);
305
+ /** Which wire protocols each provider can be driven through. */
306
+ export const PROVIDER_APIS = Object.freeze({
307
+ copilot: Object.freeze(['anthropic', 'openai-chat']),
308
+ openai: Object.freeze(['openai-chat']),
309
+ anthropic: Object.freeze(['anthropic']),
310
+ });
311
+ /** Env keys the bridge owns; rejected in an `env` map beside `upstream`. */
312
+ export const BRIDGE_ROUTING_KEYS = Object.freeze([
313
+ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL',
314
+ ]);
315
+ /** Boolean capability flags an entry may pin (model-bridge-design.md §5.6). */
316
+ export const CAPABILITY_FLAGS = Object.freeze(['toolCalls', 'vision', 'reasoning']);
317
+ /** Numeric capability limits an entry may pin. */
318
+ export const CAPABILITY_LIMITS = Object.freeze(['maxPromptTokens', 'maxOutputTokens']);
319
+ const FORBIDDEN_UPSTREAM_HEADERS = new Set(['authorization', 'host', 'content-length', 'content-type', 'transfer-encoding']);
320
+ const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,80}$/;
321
+
322
+ /** Validate a `capabilities` map; returns the normalized map or undefined. Throws. */
323
+ export function assertModelCapabilities(caps) {
324
+ if (caps === undefined || caps === null) return undefined;
325
+ if (typeof caps !== 'object' || Array.isArray(caps)) throw new Error('upstream.capabilities must be an object');
326
+ const out = {};
327
+ for (const [k, v] of Object.entries(caps)) {
328
+ if (CAPABILITY_FLAGS.includes(k)) {
329
+ if (v === null || v === undefined) continue;
330
+ if (typeof v !== 'boolean') throw new Error(`upstream.capabilities.${k} must be true or false`);
331
+ out[k] = v;
332
+ } else if (CAPABILITY_LIMITS.includes(k)) {
333
+ if (v === null || v === undefined || v === '') continue;
334
+ const n = Number(v);
335
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`upstream.capabilities.${k} must be a positive integer`);
336
+ out[k] = n;
337
+ } else {
338
+ throw new Error(`unknown upstream.capabilities key ${JSON.stringify(k)} — allowed: ${[...CAPABILITY_FLAGS, ...CAPABILITY_LIMITS].join(', ')}`);
339
+ }
340
+ }
341
+ return Object.keys(out).length ? out : undefined;
342
+ }
343
+
344
+ /** Whether `v` is an acceptable http(s) base URL with no query or fragment. */
345
+ export function isUpstreamBaseUrl(v) {
346
+ if (typeof v !== 'string' || !v.trim()) return false;
347
+ let u;
348
+ try { u = new URL(v.trim()); } catch { return false; }
349
+ return (u.protocol === 'http:' || u.protocol === 'https:') && !u.search && !u.hash;
350
+ }
351
+
352
+ /**
353
+ * Whether an OpenAI-compatible base URL points at this machine or a private
354
+ * network — llama.cpp, Ollama, LM Studio, a LAN vLLM — which typically take no
355
+ * API key. The bridge then treats the key as optional instead of refusing the
356
+ * model as "needs API key".
357
+ */
358
+ export function isLocalBaseUrl(v) {
359
+ if (!isUpstreamBaseUrl(v)) return false;
360
+ const host = new URL(v.trim()).hostname.toLowerCase().replace(/^\[|\]$/g, '');
361
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
362
+ if (host === '::1' || /^f[cd][0-9a-f]{2}:/.test(host) || host.startsWith('fe80:')) return true;
363
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(host);
364
+ if (!m) return false;
365
+ const [a, b] = [Number(m[1]), Number(m[2])];
366
+ return a === 127 || a === 10 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
367
+ }
368
+
369
+ /**
370
+ * Validate a model `upstream` block. Returns the normalized shape or undefined
371
+ * (for null/undefined); THROWS on malformed input with a message naming the
372
+ * field. Secrets (`apiKey`) are literal strings or whole-value `${VAR}` refs.
373
+ * @param {*} upstream
374
+ * @returns {{provider:string, api:string, model:string, baseUrl?:string, apiKey?:string, headers?:Record<string,string>, capabilities?:object}|undefined}
375
+ * @throws {Error}
376
+ */
377
+ export function assertModelUpstream(upstream) {
378
+ if (upstream === undefined || upstream === null) return undefined;
379
+ if (typeof upstream !== 'object' || Array.isArray(upstream)) throw new Error('upstream must be an object');
380
+ const provider = typeof upstream.provider === 'string' ? upstream.provider.trim() : '';
381
+ if (!UPSTREAM_PROVIDERS.includes(provider)) {
382
+ throw new Error(`upstream.provider must be one of ${UPSTREAM_PROVIDERS.join(' | ')}`);
383
+ }
384
+ const api = typeof upstream.api === 'string' ? upstream.api.trim() : '';
385
+ if (!UPSTREAM_APIS.includes(api)) throw new Error(`upstream.api must be one of ${UPSTREAM_APIS.join(' | ')}`);
386
+ if (!PROVIDER_APIS[provider].includes(api)) {
387
+ throw new Error(`provider ${provider} cannot be driven through api ${api} — allowed: ${PROVIDER_APIS[provider].join(' | ')}`);
388
+ }
389
+ const model = typeof upstream.model === 'string' ? upstream.model.trim() : '';
390
+ if (!model) throw new Error('upstream.model must be a non-empty string (the id the endpoint expects)');
391
+ const out = { provider, api, model };
392
+ if (upstream.baseUrl !== undefined && upstream.baseUrl !== null && upstream.baseUrl !== '') {
393
+ if (provider === 'copilot') throw new Error('upstream.baseUrl cannot be set for the copilot provider (the host follows the account type)');
394
+ if (!isUpstreamBaseUrl(upstream.baseUrl)) throw new Error('upstream.baseUrl must be an http(s) URL with no query or fragment');
395
+ out.baseUrl = upstream.baseUrl.trim().replace(/\/+$/, '');
396
+ }
397
+ if (upstream.apiKey !== undefined && upstream.apiKey !== null && upstream.apiKey !== '') {
398
+ if (provider === 'copilot') throw new Error('upstream.apiKey cannot be set for the copilot provider (sign in instead)');
399
+ if (typeof upstream.apiKey !== 'string' || !upstream.apiKey.trim()) throw new Error('upstream.apiKey must be a non-empty string or ${VAR}');
400
+ out.apiKey = upstream.apiKey.trim();
401
+ }
402
+ if (upstream.headers !== undefined && upstream.headers !== null) {
403
+ const h = upstream.headers;
404
+ if (typeof h !== 'object' || Array.isArray(h)) throw new Error('upstream.headers must be an object of string values');
405
+ const headers = {};
406
+ for (const [k, v] of Object.entries(h)) {
407
+ if (!HEADER_NAME_RE.test(k)) throw new Error(`upstream.headers: invalid header name ${JSON.stringify(k)}`);
408
+ if (FORBIDDEN_UPSTREAM_HEADERS.has(k.toLowerCase())) throw new Error(`upstream.headers: ${k} is set by the bridge and cannot be overridden`);
409
+ if (typeof v !== 'string' || !v.trim()) throw new Error(`upstream.headers: value for ${JSON.stringify(k)} must be a non-empty string`);
410
+ headers[k] = v.trim();
411
+ }
412
+ if (Object.keys(headers).length) out.headers = headers;
413
+ }
414
+ const caps = assertModelCapabilities(upstream.capabilities);
415
+ if (caps) out.capabilities = caps;
416
+ return out;
417
+ }
418
+
419
+ /** The first env key an `upstream` entry may not also carry, or null. */
420
+ export function upstreamEnvConflict(env) {
421
+ for (const k of Object.keys(env || {})) if (BRIDGE_ROUTING_KEYS.includes(k)) return k;
422
+ return null;
423
+ }
424
+
425
+ /** Whether an `openai-chat` upstream drops the CLI's web tools (§5.3). */
426
+ export function bridgeExcludedTools(upstream) {
427
+ return upstream && upstream.api === 'openai-chat' ? ['WebSearch', 'WebFetch'] : [];
428
+ }
429
+
430
+ // ── providers: account-level state shared by bridged entries (§6.2) ─────────
431
+ export const COPILOT_ACCOUNT_TYPES = Object.freeze(['individual', 'business', 'enterprise']);
432
+ export const DEFAULT_PROVIDER_CONCURRENCY = Object.freeze({ copilot: 4, openai: 8, anthropic: 8 });
433
+ export const MAX_PROVIDER_CONCURRENCY = 64;
434
+ /** Bump when the Copilot terms notice wording changes materially — a stored
435
+ * acknowledgement of an older version is shown again (§8.2). */
436
+ export const COPILOT_TERMS_VERSION = 1;
@@ -29,6 +29,16 @@ export function hintFor(errorClass) {
29
29
  }
30
30
  }
31
31
 
32
+ /** Actionable hint for a bridge readiness failure (config.mjs resolveModelEnv). Pure. */
33
+ export function bridgeHintFor(reason, provider = 'the provider') {
34
+ switch (reason) {
35
+ case 'not_signed_in': return `sign in to ${provider} under Settings › Providers (or \`worca models login ${provider}\`)`;
36
+ case 'terms': return 'acknowledge the GitHub Copilot notice under Settings › Providers first';
37
+ case 'no_key': return `set an API key for ${provider} under Settings › Providers, or on this model's Connection`;
38
+ default: return '';
39
+ }
40
+ }
41
+
32
42
  /**
33
43
  * Live connectivity check for a catalog model id (global or plugin — the
34
44
  * resolution precedence is resolveModelEnv's). Explicit user action only.
@@ -71,7 +81,10 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
71
81
  }
72
82
  const message = err && err.message ? err.message : String(err);
73
83
  const errorClass = (err && err.errorClass) || classifyError(message);
74
- const hint = hintFor(errorClass);
84
+ // A bridged model whose provider is not usable (model-bridge-design.md
85
+ // §8.5): resolveModelEnv fails fast with `bridgeReason`, and the hint
86
+ // names the fix instead of the generic credential advice.
87
+ const hint = err && err.bridgeReason ? bridgeHintFor(err.bridgeReason, err.bridgeProvider) : hintFor(errorClass);
75
88
  return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
76
89
  } finally {
77
90
  clearTimeout(timer);
@@ -0,0 +1,128 @@
1
+ // src/core/notifications.mjs
2
+ // The generic, append-only notification log (schema v31). Today only scope
3
+ // 'schedule' is written — every scheduled-run event (missed, failed, skipped, late,
4
+ // self-paused, completed) lands here — but the table carries a `scope` column so an
5
+ // app-wide centre can reuse it without a second system.
6
+ //
7
+ // Read state is GLOBAL (Worca is single-user per machine). `info` rows arrive
8
+ // already read: only `problem` rows count towards the unread badge. A problem
9
+ // RESOLVES itself when the user acts on it (Run now, reschedule, resume), so stale
10
+ // alarms never pile up.
11
+ //
12
+ // Sync (node:sqlite) like every other store module. Listeners registered with
13
+ // onNotification() fire in THIS process only; a row written by another process
14
+ // (a `--wait` CLI) is picked up by the server tick through latestNotificationId().
15
+
16
+ import { getDb } from './db.mjs';
17
+
18
+ export const NOTIFICATION_SEVERITIES = ['problem', 'info'];
19
+ export const NOTIFICATION_RETENTION_DAYS = 90;
20
+
21
+ const listeners = new Set();
22
+
23
+ /** Subscribe to rows added in this process. Returns a disposer. */
24
+ export function onNotification(fn) {
25
+ listeners.add(fn);
26
+ return () => listeners.delete(fn);
27
+ }
28
+
29
+ function rowToNotification(r) {
30
+ if (!r) return null;
31
+ return {
32
+ id: r.id,
33
+ scope: r.scope,
34
+ kind: r.kind,
35
+ severity: r.severity,
36
+ scheduleId: r.schedule_id || null,
37
+ ticketId: r.ticket_id || null,
38
+ pipelineId: r.pipeline_id || null,
39
+ projectDir: r.project_dir || null,
40
+ title: r.title || null,
41
+ message: r.message,
42
+ createdAt: r.created_at,
43
+ readAt: r.read_at || null,
44
+ resolvedAt: r.resolved_at || null,
45
+ unread: !r.read_at && r.severity === 'problem',
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Append one notification. `info` rows are stamped read at creation.
51
+ * @returns {object} the stored notification
52
+ */
53
+ export function addNotification({
54
+ scope = 'schedule', kind, severity = 'problem', scheduleId = null, ticketId = null,
55
+ pipelineId = null, projectDir = null, title = null, message, now = new Date(),
56
+ }) {
57
+ if (!kind || !message) throw new Error('addNotification: kind and message are required');
58
+ const sev = NOTIFICATION_SEVERITIES.includes(severity) ? severity : 'problem';
59
+ const ts = now.toISOString();
60
+ const info = getDb().prepare(`
61
+ INSERT INTO notifications (scope, kind, severity, schedule_id, ticket_id, pipeline_id, project_dir, title, message, created_at, read_at)
62
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
+ `).run(scope, kind, sev, scheduleId, ticketId, pipelineId, projectDir, title, String(message), ts, sev === 'info' ? ts : null);
64
+ const row = rowToNotification(getDb().prepare('SELECT * FROM notifications WHERE id = ?').get(Number(info.lastInsertRowid)));
65
+ for (const fn of listeners) {
66
+ try { fn(row); } catch { /* a listener must never break the writer */ }
67
+ }
68
+ return row;
69
+ }
70
+
71
+ /**
72
+ * List notifications, newest first.
73
+ * @param {{scope?:string, unread?:boolean, problems?:boolean, scheduleId?:string, limit?:number}} [o]
74
+ */
75
+ export function listNotifications({ scope = 'schedule', unread = false, problems = false, scheduleId = null, limit = 200 } = {}) {
76
+ const where = ['scope = ?'];
77
+ const args = [scope];
78
+ if (unread) where.push("read_at IS NULL AND severity = 'problem'");
79
+ if (problems) where.push("severity = 'problem'");
80
+ if (scheduleId) { where.push('schedule_id = ?'); args.push(scheduleId); }
81
+ const lim = Math.max(1, Math.min(1000, Number(limit) || 200));
82
+ return getDb().prepare(`SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT ${lim}`)
83
+ .all(...args).map(rowToNotification);
84
+ }
85
+
86
+ /** Unread PROBLEM rows in a scope — the sidebar badge. */
87
+ export function unreadCount(scope = 'schedule') {
88
+ return getDb().prepare("SELECT COUNT(*) AS n FROM notifications WHERE scope = ? AND read_at IS NULL AND severity = 'problem'").get(scope).n;
89
+ }
90
+
91
+ /** Highest id in the table (0 when empty) — a cheap cross-process change probe. */
92
+ export function latestNotificationId() {
93
+ return getDb().prepare('SELECT COALESCE(MAX(id), 0) AS n FROM notifications').get().n;
94
+ }
95
+
96
+ /** Mark one row read (or unread with `read:false`). @returns {boolean} whether a row changed */
97
+ export function markRead(id, { read = true, now = new Date() } = {}) {
98
+ const info = getDb().prepare('UPDATE notifications SET read_at = ? WHERE id = ?').run(read ? now.toISOString() : null, Number(id));
99
+ return info.changes > 0;
100
+ }
101
+
102
+ /** Mark every unread row of a scope read. @returns {number} rows changed */
103
+ export function markAllRead(scope = 'schedule', { now = new Date() } = {}) {
104
+ return getDb().prepare('UPDATE notifications SET read_at = ? WHERE scope = ? AND read_at IS NULL').run(now.toISOString(), scope).changes;
105
+ }
106
+
107
+ /**
108
+ * Resolve (and mark read) the open problems of a ticket and/or schedule — called when
109
+ * the user acts on them. `kinds` narrows it; omitted = every problem kind.
110
+ * @returns {number} rows changed
111
+ */
112
+ export function resolveNotifications({ ticketId = null, scheduleId = null, kinds = null, now = new Date() } = {}) {
113
+ if (!ticketId && !scheduleId) return 0;
114
+ const where = ["severity = 'problem'", 'resolved_at IS NULL'];
115
+ const args = [];
116
+ if (ticketId) { where.push('ticket_id = ?'); args.push(ticketId); }
117
+ if (scheduleId) { where.push('schedule_id = ?'); args.push(scheduleId); }
118
+ if (Array.isArray(kinds) && kinds.length) { where.push(`kind IN (${kinds.map(() => '?').join(', ')})`); args.push(...kinds); }
119
+ const ts = now.toISOString();
120
+ return getDb().prepare(`UPDATE notifications SET resolved_at = ?, read_at = COALESCE(read_at, ?) WHERE ${where.join(' AND ')}`)
121
+ .run(ts, ts, ...args).changes;
122
+ }
123
+
124
+ /** Drop rows older than the retention window. @returns {number} rows removed */
125
+ export function purgeNotifications({ days = NOTIFICATION_RETENTION_DAYS, now = new Date() } = {}) {
126
+ const cutoff = new Date(now.getTime() - days * 86400000).toISOString();
127
+ return getDb().prepare('DELETE FROM notifications WHERE created_at < ?').run(cutoff).changes;
128
+ }
@@ -12,13 +12,14 @@ import { countProjects } from './projects.mjs';
12
12
  import { countWorkspaces } from './workspaces.mjs';
13
13
  import { countThreads } from './ask/store.mjs';
14
14
  import { listScopes } from './metrics/read.mjs';
15
+ import { listPolicyScopes } from './policy/sync.mjs';
15
16
  import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
16
17
  import { onboardingPrefs } from './settings.mjs';
17
18
 
18
19
  /** Step ids in shelf order. The UI (ui/public/getting-started.mjs) carries the
19
20
  * copy and artwork for each; this list is the contract between the two. */
20
21
  export const ONBOARDING_STEPS = Object.freeze([
21
- 'claude', 'project', 'run', 'ask', 'workflows', 'realRun', 'workspace', 'teamMetrics',
22
+ 'claude', 'project', 'run', 'ask', 'realRun', 'workflows', 'workspace', 'teamMetrics', 'teamPolicy',
22
23
  ]);
23
24
 
24
25
  /** The configured Claude binary — the same precedence claude-runner.mjs spawns with. */
@@ -69,7 +70,7 @@ export function claudeReady(bin = configuredClaudeBin(), opts = {}) {
69
70
  }
70
71
 
71
72
  /**
72
- * The eight ticks plus the two stored flags, in one call.
73
+ * The nine ticks plus the two stored flags, in one call.
73
74
  * @returns {Promise<{steps:Record<string,boolean>, done:number, total:number,
74
75
  * claude:{bin:string, hint:string|null}, hidden:boolean, welcomeSeen:boolean}>}
75
76
  */
@@ -81,6 +82,10 @@ export async function onboardingStatus() {
81
82
  // Cached status only (no discovery): this is read at boot and after every change
82
83
  // broadcast, and the Team metrics page owns the expensive refresh.
83
84
  try { teamMetrics = !!(await listScopes()).anyEnabled; } catch { /* offline / no git: not enabled */ }
85
+ // Same for the policy: a project or workspace that RESOLVES a policy (its own home or one it
86
+ // follows) from the cached branch reads — never a discovery.
87
+ let teamPolicy = false;
88
+ try { teamPolicy = !!(await listPolicyScopes()).anyEnabled; } catch { /* offline / no git: not enabled */ }
84
89
  const steps = {
85
90
  claude: claude.ready,
86
91
  project: countProjects() > 0,
@@ -93,6 +98,7 @@ export async function onboardingStatus() {
93
98
  workflows: count("SELECT COUNT(*) AS n FROM project_config WHERE active_workflow_id IS NOT NULL AND TRIM(active_workflow_id) != ''") > 0,
94
99
  workspace: countWorkspaces() > 0,
95
100
  teamMetrics,
101
+ teamPolicy,
96
102
  };
97
103
  const done = ONBOARDING_STEPS.filter((id) => steps[id]).length;
98
104
  return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint }, ...onboardingPrefs() };