crewly 1.8.8 → 1.8.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.
Files changed (139) hide show
  1. package/config/roles/_common/memory-instructions.md +6 -5
  2. package/config/roles/_common/wiki-instructions.md +49 -0
  3. package/config/roles/architect/prompt.md +2 -2
  4. package/config/roles/backend-developer/prompt.md +2 -2
  5. package/config/roles/designer/prompt.md +2 -2
  6. package/config/roles/developer/prompt.md +2 -2
  7. package/config/roles/frontend-developer/prompt.md +2 -2
  8. package/config/roles/fullstack-dev/prompt.md +2 -2
  9. package/config/roles/generalist/prompt.md +2 -2
  10. package/config/roles/ops/prompt.md +2 -2
  11. package/config/roles/orchestrator/prompt.md +135 -11
  12. package/config/roles/product-manager/prompt.md +2 -2
  13. package/config/roles/qa/prompt.md +2 -2
  14. package/config/roles/qa-engineer/prompt.md +2 -2
  15. package/config/roles/researcher/prompt.md +15 -6
  16. package/config/roles/sales/prompt.md +2 -2
  17. package/config/roles/support/prompt.md +2 -2
  18. package/config/roles/team-leader/prompt.md +17 -2
  19. package/config/roles/tpm/prompt.md +2 -2
  20. package/config/roles/ux-designer/prompt.md +2 -2
  21. package/config/skills/orchestrator/wiki-cleanup/SKILL.md +89 -0
  22. package/config/skills/orchestrator/wiki-cleanup/execute.sh +139 -0
  23. package/config/skills/orchestrator/wiki-lint/SKILL.md +75 -0
  24. package/config/skills/orchestrator/wiki-lint/execute.sh +66 -0
  25. package/config/skills/orchestrator/wiki-migrate/SKILL.md +103 -0
  26. package/config/skills/orchestrator/wiki-migrate/execute.sh +82 -0
  27. package/config/skills/orchestrator/wiki-process-queue/SKILL.md +9 -1
  28. package/dist/backend/backend/src/constants.d.ts +12 -0
  29. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  30. package/dist/backend/backend/src/constants.js +12 -0
  31. package/dist/backend/backend/src/constants.js.map +1 -1
  32. package/dist/backend/backend/src/controllers/browser/browser.controller.d.ts.map +1 -1
  33. package/dist/backend/backend/src/controllers/browser/browser.controller.js +17 -0
  34. package/dist/backend/backend/src/controllers/browser/browser.controller.js.map +1 -1
  35. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  36. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +8 -1
  37. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  38. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts +18 -0
  39. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.d.ts.map +1 -1
  40. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js +63 -0
  41. package/dist/backend/backend/src/controllers/task-pool/task-pool.controller.js.map +1 -1
  42. package/dist/backend/backend/src/controllers/task-pool/task-pool.routes.d.ts.map +1 -1
  43. package/dist/backend/backend/src/controllers/task-pool/task-pool.routes.js +5 -1
  44. package/dist/backend/backend/src/controllers/task-pool/task-pool.routes.js.map +1 -1
  45. package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts +109 -0
  46. package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts.map +1 -1
  47. package/dist/backend/backend/src/controllers/wiki/wiki.controller.js +418 -4
  48. package/dist/backend/backend/src/controllers/wiki/wiki.controller.js.map +1 -1
  49. package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts.map +1 -1
  50. package/dist/backend/backend/src/controllers/wiki/wiki.routes.js +11 -1
  51. package/dist/backend/backend/src/controllers/wiki/wiki.routes.js.map +1 -1
  52. package/dist/backend/backend/src/index.d.ts.map +1 -1
  53. package/dist/backend/backend/src/index.js +79 -7
  54. package/dist/backend/backend/src/index.js.map +1 -1
  55. package/dist/backend/backend/src/index.js.orc-bak-20260529 +3130 -0
  56. package/dist/backend/backend/src/services/ai/prompt-builder.service.js +1 -1
  57. package/dist/backend/backend/src/services/browser/browser-bridge.service.d.ts.map +1 -1
  58. package/dist/backend/backend/src/services/browser/browser-bridge.service.js +15 -29
  59. package/dist/backend/backend/src/services/browser/browser-bridge.service.js.map +1 -1
  60. package/dist/backend/backend/src/services/browser/browser-proxy.service.d.ts +97 -1
  61. package/dist/backend/backend/src/services/browser/browser-proxy.service.d.ts.map +1 -1
  62. package/dist/backend/backend/src/services/browser/browser-proxy.service.js +174 -15
  63. package/dist/backend/backend/src/services/browser/browser-proxy.service.js.map +1 -1
  64. package/dist/backend/backend/src/services/browser/browser-relay-adapter.service.d.ts +12 -4
  65. package/dist/backend/backend/src/services/browser/browser-relay-adapter.service.d.ts.map +1 -1
  66. package/dist/backend/backend/src/services/browser/browser-relay-adapter.service.js +17 -5
  67. package/dist/backend/backend/src/services/browser/browser-relay-adapter.service.js.map +1 -1
  68. package/dist/backend/backend/src/services/cloud/cloud-client.service.d.ts +75 -0
  69. package/dist/backend/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/cloud/cloud-client.service.js +164 -12
  71. package/dist/backend/backend/src/services/cloud/cloud-client.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.d.ts.map +1 -1
  73. package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.js +50 -0
  74. package/dist/backend/backend/src/services/reconciler/reconciler-data-provider.js.map +1 -1
  75. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts +19 -0
  76. package/dist/backend/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  77. package/dist/backend/backend/src/services/task-pool/task-pool.service.js +45 -0
  78. package/dist/backend/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  79. package/dist/backend/backend/src/services/v3/agent-auto-claim.service.d.ts.map +1 -1
  80. package/dist/backend/backend/src/services/v3/agent-auto-claim.service.js +34 -1
  81. package/dist/backend/backend/src/services/v3/agent-auto-claim.service.js.map +1 -1
  82. package/dist/backend/backend/src/services/wiki/wiki-backlinks.service.d.ts +72 -0
  83. package/dist/backend/backend/src/services/wiki/wiki-backlinks.service.d.ts.map +1 -0
  84. package/dist/backend/backend/src/services/wiki/wiki-backlinks.service.js +186 -0
  85. package/dist/backend/backend/src/services/wiki/wiki-backlinks.service.js.map +1 -0
  86. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts +4 -1
  87. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts.map +1 -1
  88. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js +24 -1
  89. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js.map +1 -1
  90. package/dist/backend/backend/src/services/wiki/wiki-cleanup.service.d.ts +160 -0
  91. package/dist/backend/backend/src/services/wiki/wiki-cleanup.service.d.ts.map +1 -0
  92. package/dist/backend/backend/src/services/wiki/wiki-cleanup.service.js +399 -0
  93. package/dist/backend/backend/src/services/wiki/wiki-cleanup.service.js.map +1 -0
  94. package/dist/backend/backend/src/services/wiki/wiki-lint.service.d.ts +182 -0
  95. package/dist/backend/backend/src/services/wiki/wiki-lint.service.d.ts.map +1 -0
  96. package/dist/backend/backend/src/services/wiki/wiki-lint.service.js +505 -0
  97. package/dist/backend/backend/src/services/wiki/wiki-lint.service.js.map +1 -0
  98. package/dist/backend/backend/src/services/wiki/wiki-migrate.service.d.ts +232 -0
  99. package/dist/backend/backend/src/services/wiki/wiki-migrate.service.d.ts.map +1 -0
  100. package/dist/backend/backend/src/services/wiki/wiki-migrate.service.js +1416 -0
  101. package/dist/backend/backend/src/services/wiki/wiki-migrate.service.js.map +1 -0
  102. package/dist/backend/backend/src/services/wiki/wiki-recent.service.d.ts +51 -0
  103. package/dist/backend/backend/src/services/wiki/wiki-recent.service.d.ts.map +1 -0
  104. package/dist/backend/backend/src/services/wiki/wiki-recent.service.js +102 -0
  105. package/dist/backend/backend/src/services/wiki/wiki-recent.service.js.map +1 -0
  106. package/dist/backend/backend/src/services/wiki/wiki-reflect-trigger.service.d.ts +84 -0
  107. package/dist/backend/backend/src/services/wiki/wiki-reflect-trigger.service.d.ts.map +1 -0
  108. package/dist/backend/backend/src/services/wiki/wiki-reflect-trigger.service.js +156 -0
  109. package/dist/backend/backend/src/services/wiki/wiki-reflect-trigger.service.js.map +1 -0
  110. package/dist/backend/backend/src/services/wiki/wiki-search.service.d.ts +90 -0
  111. package/dist/backend/backend/src/services/wiki/wiki-search.service.d.ts.map +1 -0
  112. package/dist/backend/backend/src/services/wiki/wiki-search.service.js +190 -0
  113. package/dist/backend/backend/src/services/wiki/wiki-search.service.js.map +1 -0
  114. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.d.ts +164 -0
  115. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.d.ts.map +1 -0
  116. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.js +675 -0
  117. package/dist/backend/backend/src/services/wiki/wiki-workitem-bridge.service.js.map +1 -0
  118. package/dist/backend/backend/src/services/workflow/cron-task.service.d.ts.map +1 -1
  119. package/dist/backend/backend/src/services/workflow/cron-task.service.js +65 -0
  120. package/dist/backend/backend/src/services/workflow/cron-task.service.js.map +1 -1
  121. package/dist/backend/backend/src/types/cron-task.types.d.ts +16 -1
  122. package/dist/backend/backend/src/types/cron-task.types.d.ts.map +1 -1
  123. package/dist/cli/backend/src/constants.d.ts +12 -0
  124. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  125. package/dist/cli/backend/src/constants.js +12 -0
  126. package/dist/cli/backend/src/constants.js.map +1 -1
  127. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts +19 -0
  128. package/dist/cli/backend/src/services/task-pool/task-pool.service.d.ts.map +1 -1
  129. package/dist/cli/backend/src/services/task-pool/task-pool.service.js +45 -0
  130. package/dist/cli/backend/src/services/task-pool/task-pool.service.js.map +1 -1
  131. package/frontend/dist/assets/{index-db3f5041.css → index-068bb4f6.css} +10 -1
  132. package/frontend/dist/assets/index-c24ceb15.js +4960 -0
  133. package/frontend/dist/index.html +2 -2
  134. package/package.json +1 -1
  135. package/config/skills/agent/core/query-knowledge/SKILL.md +0 -87
  136. package/config/skills/agent/core/query-knowledge/execute.sh +0 -30
  137. package/config/skills/orchestrator/query-knowledge/SKILL.md +0 -75
  138. package/config/skills/orchestrator/query-knowledge/execute.sh +0 -30
  139. package/frontend/dist/assets/index-cc115bb4.js +0 -4926
@@ -0,0 +1,675 @@
1
+ /**
2
+ * WikiWorkItemBridgeService — materialises pending wiki work as claimable WIs.
3
+ *
4
+ * Replaces the wiki-bookkeep / wiki-reflect "shouldFire ≥ threshold" cron
5
+ * model with the same V3 pool the rest of the system uses. Two source
6
+ * streams feed the pool:
7
+ *
8
+ * 1. Pending `wiki-queue` items (one WI per vault that has ≥ 1 pending
9
+ * item; the agent drains the whole vault in that WI).
10
+ * 2. Legacy `.crewly/knowledge/*` migration candidates per project (one
11
+ * WI per project root with > 0 proposed pages).
12
+ *
13
+ * Idle agents claim them through `TaskPoolService.claimFromPool` — no
14
+ * extra cron decisions about who to ping. Failures route through the
15
+ * standard task:failed / reconciler escalation paths (closing the loop
16
+ * with the 2026-05-27 reconciler-escalation fix in
17
+ * `reconciler-data-provider.ts:applyCorrection`).
18
+ *
19
+ * PR-1 routing (conservative): both kinds target the orchestrator
20
+ * session. The wiki skills today live under `config/skills/orchestrator/`,
21
+ * so ORC is the only role with a built-in handler. Broadening to TLs is
22
+ * a follow-up once the skills are promoted to a shared path.
23
+ *
24
+ * Dedupe: at most one non-terminal WI per (kind, vaultPath/projectRoot).
25
+ *
26
+ * @module services/wiki/wiki-workitem-bridge.service
27
+ */
28
+ import os from 'os';
29
+ import path from 'path';
30
+ import { existsSync, promises as fs } from 'fs';
31
+ import { LoggerService } from '../core/logger.service.js';
32
+ import { TaskPoolService } from '../task-pool/task-pool.service.js';
33
+ import { WikiQueueService } from './wiki-queue.service.js';
34
+ import { WikiMigrateService } from './wiki-migrate.service.js';
35
+ import { WikiCleanupService } from './wiki-cleanup.service.js';
36
+ import { discoverWikiVaults } from './wiki-bookkeep-trigger.service.js';
37
+ import { createWorkItem } from '../../types/v2/work-item.types.js';
38
+ import { atomicWriteJson, safeReadJson, ensureDir } from '../../utils/file-io.utils.js';
39
+ import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
40
+ // ---------------------------------------------------------------------------
41
+ // Constants
42
+ // ---------------------------------------------------------------------------
43
+ const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
44
+ const DEFAULT_TARGET_AGENT = 'crewly-orc';
45
+ /**
46
+ * Hard cap on how many new WIs a single tick can publish. PTY-backed
47
+ * agents (Claude Code) flood their paste buffer when too many
48
+ * `[SYSTEM — TASK RECOVERY]` messages arrive in rapid succession —
49
+ * observed during the 2026-05-27 first boot of the bridge: 11 WIs hit
50
+ * ORC in 0.97s, the PTY buffer choked, and ORC sat "Reticulating…" for
51
+ * 13 min until the reconciler revoked the claim. Two per tick gives
52
+ * the worker breathing room; the next interval will pick up the rest.
53
+ */
54
+ const DEFAULT_MAX_CREATES_PER_TICK = 2;
55
+ /**
56
+ * Cooldown between creating WIs for the same (kind, vault/project) key.
57
+ * Default 30 min.
58
+ *
59
+ * Why: pool-only dedupe is insufficient when ORC's recovery flow forcibly
60
+ * deletes WIs via `DELETE /api/task-pool/:id?force=1` to escape its own
61
+ * PTY-flood state (observed 2026-05-27 22:46:49, post-session-restart
62
+ * cleanup wiped 10 wiki WIs). A removed WI is no longer in the pool, so
63
+ * the next tick's `snapshotInflight` misses it and re-creates a duplicate
64
+ * with a fresh id. Cooldown blocks that loop by tracking
65
+ * "last attempted create" in-memory regardless of pool state.
66
+ */
67
+ const DEFAULT_COOLDOWN_MS = 30 * 60 * 1000;
68
+ /**
69
+ * State-file name (under {@link getCrewlyHomePath}). Used to persist
70
+ * {@link WikiWorkItemBridgeService.lastCreatedAt} across restarts so an
71
+ * operator's cancel decision isn't undone by the next boot tick. Without
72
+ * persistence: cancel a WI → restart → bridge's in-memory cooldown is
73
+ * empty → re-creates the WI within seconds.
74
+ */
75
+ const STATE_FILE_NAME = 'wiki-bridge-state.json';
76
+ /** Persisted state version — bump on shape change to invalidate old files. */
77
+ const STATE_VERSION = 1;
78
+ /**
79
+ * Cap on entries in the persisted map. Old entries are no-ops anyway
80
+ * (their cooldown has long expired), but pruning keeps the file small.
81
+ */
82
+ const STATE_MAX_ENTRIES = 1000;
83
+ function emptyState() {
84
+ return { version: STATE_VERSION, lastCreatedAt: {} };
85
+ }
86
+ /** WorkItem.metadata.kind value identifying a wiki queue drain WI. */
87
+ export const META_KIND_WIKI_DRAIN = 'wiki_queue_drain';
88
+ /** WorkItem.metadata.kind value identifying a wiki legacy migration WI. */
89
+ export const META_KIND_WIKI_MIGRATE = 'wiki_legacy_migrate';
90
+ /** WorkItem.metadata.kind value identifying a wiki cleanup (low-quality removal) WI. */
91
+ export const META_KIND_WIKI_CLEANUP = 'wiki_quality_cleanup';
92
+ /**
93
+ * Per-WI chunk size for cleanup. The bridge surfaces at most this many
94
+ * candidate page paths per WorkItem; if a vault has more candidates,
95
+ * the bridge creates one WI per chunk on successive ticks. 10 keeps the
96
+ * agent's per-WI workload small enough to actually read each page
97
+ * before deciding keep/delete.
98
+ */
99
+ const CLEANUP_CHUNK_SIZE = 10;
100
+ /**
101
+ * Skip cleanup-WI creation until the vault has at least this many
102
+ * candidates. Avoids spawning a WI for a vault that has 1-2 minor
103
+ * blemishes — the bridge has better things to do.
104
+ */
105
+ const CLEANUP_MIN_CANDIDATES = 10;
106
+ const TERMINAL_STATUSES = new Set([
107
+ 'done',
108
+ 'verified',
109
+ 'cancelled',
110
+ 'failed',
111
+ 'rejected',
112
+ ]);
113
+ /**
114
+ * Periodic bridge: pending wiki queue items + migration candidates
115
+ * become claimable WorkItems in the V3 pool.
116
+ */
117
+ export class WikiWorkItemBridgeService {
118
+ static instance = null;
119
+ logger;
120
+ intervalMs;
121
+ targetAgent;
122
+ maxCreatesPerTick;
123
+ cooldownMs;
124
+ nowFn;
125
+ discoverRoots;
126
+ discoverProjectRoots;
127
+ queue;
128
+ migrate;
129
+ cleanup;
130
+ pool;
131
+ timer = null;
132
+ running = false;
133
+ /** Per-key (kind + path) last-create timestamp. Survives pool deletes. */
134
+ lastCreatedAt = new Map();
135
+ /** Absolute path of the persisted state file (or `null` to disable). */
136
+ statePath;
137
+ /** True after `loadStateFromDisk` has either populated or no-op'd. */
138
+ stateLoaded = false;
139
+ constructor(opts = {}) {
140
+ this.logger = LoggerService.getInstance().createComponentLogger('WikiWorkItemBridge');
141
+ this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
142
+ this.targetAgent = opts.targetAgent ?? DEFAULT_TARGET_AGENT;
143
+ this.maxCreatesPerTick = Math.max(1, opts.maxCreatesPerTick ?? DEFAULT_MAX_CREATES_PER_TICK);
144
+ this.cooldownMs = Math.max(0, opts.cooldownMs ?? DEFAULT_COOLDOWN_MS);
145
+ this.nowFn = opts.now ?? (() => Date.now());
146
+ this.discoverRoots = opts.discoverRoots ?? discoverWikiVaults;
147
+ this.discoverProjectRoots = opts.discoverProjectRoots ?? defaultProjectRoots;
148
+ this.queue = opts.queueService ?? WikiQueueService.getInstance();
149
+ this.migrate = opts.migrateService ?? WikiMigrateService.getInstance();
150
+ this.cleanup = opts.cleanupService ?? WikiCleanupService.getInstance();
151
+ this.pool = opts.taskPool ?? TaskPoolService.getInstance();
152
+ if (opts.statePath === null) {
153
+ this.statePath = null;
154
+ }
155
+ else if (typeof opts.statePath === 'string') {
156
+ this.statePath = opts.statePath;
157
+ }
158
+ else {
159
+ this.statePath = path.join(getCrewlyHomePath(), STATE_FILE_NAME);
160
+ }
161
+ }
162
+ /**
163
+ * Hydrate {@link lastCreatedAt} from disk. Idempotent — runs once per
164
+ * service lifetime. Bad/missing/stale-version files reset to empty.
165
+ */
166
+ async loadStateFromDisk() {
167
+ if (this.stateLoaded)
168
+ return;
169
+ this.stateLoaded = true;
170
+ if (!this.statePath)
171
+ return;
172
+ const state = await safeReadJson(this.statePath, emptyState(), this.logger);
173
+ if (!state || state.version !== STATE_VERSION || typeof state.lastCreatedAt !== 'object') {
174
+ this.logger.debug('Bridge state file missing / wrong version — starting fresh', {
175
+ statePath: this.statePath,
176
+ loadedVersion: state?.version,
177
+ });
178
+ return;
179
+ }
180
+ let loadedCount = 0;
181
+ const now = this.nowFn();
182
+ for (const [key, ts] of Object.entries(state.lastCreatedAt)) {
183
+ if (typeof ts !== 'number')
184
+ continue;
185
+ // Drop entries already past their cooldown — they're no-ops and
186
+ // would just bloat memory.
187
+ if (now - ts >= this.cooldownMs)
188
+ continue;
189
+ this.lastCreatedAt.set(key, ts);
190
+ loadedCount++;
191
+ }
192
+ if (loadedCount > 0) {
193
+ this.logger.info('Loaded bridge cooldown state', {
194
+ statePath: this.statePath,
195
+ activeCooldowns: loadedCount,
196
+ });
197
+ }
198
+ }
199
+ /**
200
+ * Persist {@link lastCreatedAt} to disk. Best-effort — write failures
201
+ * are logged but never throw (the bridge is non-essential infrastructure).
202
+ * Prunes expired entries before writing so the file stays small.
203
+ */
204
+ async saveStateToDisk() {
205
+ if (!this.statePath)
206
+ return;
207
+ try {
208
+ const now = this.nowFn();
209
+ const fresh = {};
210
+ for (const [key, ts] of this.lastCreatedAt) {
211
+ if (now - ts >= this.cooldownMs)
212
+ continue;
213
+ fresh[key] = ts;
214
+ }
215
+ // Cap the file to STATE_MAX_ENTRIES newest by timestamp. Production
216
+ // expects ~10-50 entries; the cap is paranoia against a future
217
+ // path-explosion bug.
218
+ const entries = Object.entries(fresh);
219
+ if (entries.length > STATE_MAX_ENTRIES) {
220
+ entries.sort((a, b) => b[1] - a[1]);
221
+ entries.length = STATE_MAX_ENTRIES;
222
+ }
223
+ const payload = {
224
+ version: STATE_VERSION,
225
+ lastCreatedAt: Object.fromEntries(entries),
226
+ };
227
+ await ensureDir(path.dirname(this.statePath));
228
+ await atomicWriteJson(this.statePath, payload);
229
+ }
230
+ catch (err) {
231
+ this.logger.warn('Bridge state persist failed (non-fatal)', {
232
+ statePath: this.statePath,
233
+ error: err.message,
234
+ });
235
+ }
236
+ }
237
+ /**
238
+ * True iff the key's cooldown is still active (i.e., we created a WI
239
+ * for this key recently). Survives forced pool deletes so an
240
+ * agent-driven cleanup can't trick the bridge into re-creating
241
+ * immediately.
242
+ */
243
+ isCoolingDown(key) {
244
+ const last = this.lastCreatedAt.get(key);
245
+ if (last == null)
246
+ return false;
247
+ return this.nowFn() - last < this.cooldownMs;
248
+ }
249
+ markCreated(key) {
250
+ this.lastCreatedAt.set(key, this.nowFn());
251
+ }
252
+ static getInstance() {
253
+ return this.instance;
254
+ }
255
+ /** Wire / detach the production singleton. */
256
+ static setInstance(next) {
257
+ if (this.instance && this.instance !== next)
258
+ this.instance.stop();
259
+ this.instance = next;
260
+ }
261
+ /** Start the periodic scan. Idempotent. Fires an immediate tick. */
262
+ start() {
263
+ if (this.timer)
264
+ return;
265
+ void this.tick();
266
+ this.timer = setInterval(() => void this.tick(), this.intervalMs);
267
+ this.logger.info('WikiWorkItemBridge started', {
268
+ intervalMs: this.intervalMs,
269
+ targetAgent: this.targetAgent,
270
+ });
271
+ }
272
+ stop() {
273
+ if (this.timer) {
274
+ clearInterval(this.timer);
275
+ this.timer = null;
276
+ }
277
+ }
278
+ /** Single pass — exposed for tests and the initial boot-time fire. */
279
+ async tick() {
280
+ const result = {
281
+ scannedVaults: [],
282
+ createdForVault: [],
283
+ skippedInflightVaults: [],
284
+ scannedProjects: [],
285
+ createdForProject: [],
286
+ skippedInflightProjects: [],
287
+ deferredByThrottle: [],
288
+ skippedByCooldown: [],
289
+ createdCleanupForVault: [],
290
+ };
291
+ if (this.running) {
292
+ // Overlapping ticks would create duplicates; the dedupe pass below
293
+ // also catches it but cheaper to bail early.
294
+ this.logger.debug('Tick already in progress — skipping overlap');
295
+ return result;
296
+ }
297
+ this.running = true;
298
+ let createdThisTick = 0;
299
+ try {
300
+ await this.loadStateFromDisk();
301
+ const { inflightVaults, inflightProjects, inflightCleanupVaults } = await this.snapshotInflight();
302
+ // Drains come first — they're cheap, fast, and the most common case.
303
+ const vaults = await this.discoverRoots();
304
+ for (const vaultPath of vaults) {
305
+ result.scannedVaults.push(vaultPath);
306
+ if (inflightVaults.has(vaultPath)) {
307
+ result.skippedInflightVaults.push(vaultPath);
308
+ continue;
309
+ }
310
+ const key = `${META_KIND_WIKI_DRAIN}:${vaultPath}`;
311
+ if (this.isCoolingDown(key)) {
312
+ result.skippedByCooldown.push(vaultPath);
313
+ continue;
314
+ }
315
+ try {
316
+ const pending = await this.queue.list({
317
+ vaultPath,
318
+ status: 'pending',
319
+ limit: 200,
320
+ });
321
+ if (pending.length === 0)
322
+ continue;
323
+ if (createdThisTick >= this.maxCreatesPerTick) {
324
+ result.deferredByThrottle.push(vaultPath);
325
+ continue;
326
+ }
327
+ await this.createDrainWorkItem(vaultPath, pending.length);
328
+ this.markCreated(key);
329
+ result.createdForVault.push(vaultPath);
330
+ createdThisTick++;
331
+ }
332
+ catch (err) {
333
+ this.logger.debug('queue.list / drain WI create failed (non-fatal)', {
334
+ vaultPath,
335
+ error: err.message,
336
+ });
337
+ }
338
+ }
339
+ const projects = await this.discoverProjectRoots();
340
+ for (const projectRoot of projects) {
341
+ result.scannedProjects.push(projectRoot);
342
+ if (inflightProjects.has(projectRoot)) {
343
+ result.skippedInflightProjects.push(projectRoot);
344
+ continue;
345
+ }
346
+ const key = `${META_KIND_WIKI_MIGRATE}:${projectRoot}`;
347
+ if (this.isCoolingDown(key)) {
348
+ result.skippedByCooldown.push(projectRoot);
349
+ continue;
350
+ }
351
+ try {
352
+ const scan = await this.migrate.scan({ projectRoot });
353
+ if (!scan.ok)
354
+ continue;
355
+ if (!scan.legacyDetected)
356
+ continue;
357
+ // Count NET-NEW pages only. A scan keeps already-migrated entries in
358
+ // `proposedPages` tagged with skipReason 'already migrated' (apply-only
359
+ // reasons like write_failed never appear on a scan result). Counting
360
+ // the raw length means a fully-migrated vault still reports
361
+ // proposed > 0, so the gate below never trips and the bridge re-creates
362
+ // a no-op migrate WI every cooldown cycle (churn loop). Only pages with
363
+ // no skipReason represent genuine new work for the migrate agent.
364
+ const proposed = scan.proposedPages.filter((p) => !p.skipReason).length;
365
+ if (proposed === 0)
366
+ continue;
367
+ if (createdThisTick >= this.maxCreatesPerTick) {
368
+ result.deferredByThrottle.push(projectRoot);
369
+ continue;
370
+ }
371
+ await this.createMigrateWorkItem(projectRoot, proposed);
372
+ this.markCreated(key);
373
+ result.createdForProject.push(projectRoot);
374
+ createdThisTick++;
375
+ }
376
+ catch (err) {
377
+ this.logger.debug('migrate.scan / migrate WI create failed (non-fatal)', {
378
+ projectRoot,
379
+ error: err.message,
380
+ });
381
+ }
382
+ }
383
+ // Third pass: cleanup. Re-walk the same vault list (already scanned
384
+ // above for drain) and ask the cleanup service if it has candidates.
385
+ // Same dedupe + cooldown + throttle apply. Each cleanup WI carries
386
+ // up to CLEANUP_CHUNK_SIZE explicit page paths; if the vault has
387
+ // more, the next bridge tick (post-cooldown) creates a fresh WI
388
+ // for the next chunk — the agent doesn't have to handle all of
389
+ // it in one session.
390
+ for (const vaultPath of vaults) {
391
+ if (inflightCleanupVaults.has(vaultPath))
392
+ continue;
393
+ const cleanupKey = `${META_KIND_WIKI_CLEANUP}:${vaultPath}`;
394
+ if (this.isCoolingDown(cleanupKey)) {
395
+ result.skippedByCooldown.push(`cleanup:${vaultPath}`);
396
+ continue;
397
+ }
398
+ try {
399
+ const scan = await this.cleanup.scan({ vaultPath });
400
+ if (!scan.ok || !('candidates' in scan))
401
+ continue;
402
+ if (scan.candidates.length < CLEANUP_MIN_CANDIDATES)
403
+ continue;
404
+ if (createdThisTick >= this.maxCreatesPerTick) {
405
+ result.deferredByThrottle.push(`cleanup:${vaultPath}`);
406
+ continue;
407
+ }
408
+ await this.createCleanupWorkItem(vaultPath, scan.candidates.slice(0, CLEANUP_CHUNK_SIZE), scan.candidates.length);
409
+ this.markCreated(cleanupKey);
410
+ result.createdCleanupForVault.push(vaultPath);
411
+ createdThisTick++;
412
+ }
413
+ catch (err) {
414
+ this.logger.debug('cleanup.scan / cleanup WI create failed (non-fatal)', {
415
+ vaultPath,
416
+ error: err.message,
417
+ });
418
+ }
419
+ }
420
+ if (result.deferredByThrottle.length > 0) {
421
+ this.logger.info('WikiWorkItemBridge throttled — deferred to next tick', {
422
+ createdThisTick,
423
+ maxCreatesPerTick: this.maxCreatesPerTick,
424
+ deferredCount: result.deferredByThrottle.length,
425
+ nextTickInMs: this.intervalMs,
426
+ });
427
+ }
428
+ // Persist any state mutations from this tick. One write per tick
429
+ // amortises filesystem cost even if many keys were updated.
430
+ if (createdThisTick > 0) {
431
+ await this.saveStateToDisk();
432
+ }
433
+ }
434
+ catch (err) {
435
+ this.logger.warn('WikiWorkItemBridge tick failed (non-fatal)', {
436
+ error: err.message,
437
+ });
438
+ }
439
+ finally {
440
+ this.running = false;
441
+ }
442
+ return result;
443
+ }
444
+ /**
445
+ * Build the dedupe sets from the pool's current contents. A WI is
446
+ * "in-flight" if its kind metadata matches and its status is not
447
+ * terminal — i.e. it's still queued/claimed/running/etc.
448
+ */
449
+ async snapshotInflight() {
450
+ const inflightVaults = new Set();
451
+ const inflightProjects = new Set();
452
+ const inflightCleanupVaults = new Set();
453
+ const allItems = await this.pool.getAllItems();
454
+ for (const wi of allItems) {
455
+ if (TERMINAL_STATUSES.has(wi.status))
456
+ continue;
457
+ const meta = wi.metadata ?? {};
458
+ const kind = meta['kind'];
459
+ if (kind === META_KIND_WIKI_DRAIN && typeof meta['vaultPath'] === 'string') {
460
+ inflightVaults.add(meta['vaultPath']);
461
+ }
462
+ else if (kind === META_KIND_WIKI_MIGRATE && typeof meta['projectRoot'] === 'string') {
463
+ inflightProjects.add(meta['projectRoot']);
464
+ }
465
+ else if (kind === META_KIND_WIKI_CLEANUP && typeof meta['vaultPath'] === 'string') {
466
+ inflightCleanupVaults.add(meta['vaultPath']);
467
+ }
468
+ }
469
+ return { inflightVaults, inflightProjects, inflightCleanupVaults };
470
+ }
471
+ async createDrainWorkItem(vaultPath, pendingCount) {
472
+ const scope = describeVaultScope(vaultPath);
473
+ const wi = createWorkItem({
474
+ type: 'delegate',
475
+ owner: 'orchestrator',
476
+ target: this.targetAgent,
477
+ title: `Drain ${pendingCount} wiki queue item(s) — ${scope}`,
478
+ description: `Process ${pendingCount} pending wiki queue items in ${vaultPath}.`,
479
+ briefMarkdown: drainBrief(vaultPath, pendingCount),
480
+ maxRetries: 1,
481
+ metadata: {
482
+ kind: META_KIND_WIKI_DRAIN,
483
+ vaultPath,
484
+ pendingCount,
485
+ autoCreated: true,
486
+ },
487
+ });
488
+ await this.pool.addToPool(wi);
489
+ this.logger.info('Created wiki-drain WorkItem', {
490
+ workItemId: wi.id,
491
+ vaultPath,
492
+ pendingCount,
493
+ target: this.targetAgent,
494
+ });
495
+ }
496
+ async createCleanupWorkItem(vaultPath, chunk, totalCandidates) {
497
+ const scope = describeVaultScope(vaultPath);
498
+ const wi = createWorkItem({
499
+ type: 'delegate',
500
+ owner: 'orchestrator',
501
+ target: this.targetAgent,
502
+ title: `Cleanup ${chunk.length} of ${totalCandidates} low-quality wiki page(s) — ${scope}`,
503
+ description: `Review ${chunk.length} cleanup candidates in ${vaultPath}; decide keep/delete; call wiki-cleanup --apply with the final list.`,
504
+ briefMarkdown: cleanupBrief(vaultPath, chunk, totalCandidates),
505
+ maxRetries: 1,
506
+ metadata: {
507
+ kind: META_KIND_WIKI_CLEANUP,
508
+ vaultPath,
509
+ chunkSize: chunk.length,
510
+ totalCandidates,
511
+ candidates: chunk.map((c) => ({
512
+ relPath: c.relPath,
513
+ reasons: c.reasons,
514
+ confidence: c.confidence,
515
+ bytes: c.bytes,
516
+ })),
517
+ autoCreated: true,
518
+ },
519
+ });
520
+ await this.pool.addToPool(wi);
521
+ this.logger.info('Created wiki-cleanup WorkItem', {
522
+ workItemId: wi.id,
523
+ vaultPath,
524
+ chunkSize: chunk.length,
525
+ totalCandidates,
526
+ target: this.targetAgent,
527
+ });
528
+ }
529
+ async createMigrateWorkItem(projectRoot, proposedCount) {
530
+ const wi = createWorkItem({
531
+ type: 'delegate',
532
+ owner: 'orchestrator',
533
+ target: this.targetAgent,
534
+ title: `Migrate ${proposedCount} legacy wiki item(s) — ${path.basename(projectRoot)}`,
535
+ description: `Apply wiki-migrate for ${projectRoot} (${proposedCount} new pages).`,
536
+ briefMarkdown: migrateBrief(projectRoot, proposedCount),
537
+ maxRetries: 1,
538
+ metadata: {
539
+ kind: META_KIND_WIKI_MIGRATE,
540
+ projectRoot,
541
+ proposedCount,
542
+ autoCreated: true,
543
+ },
544
+ });
545
+ await this.pool.addToPool(wi);
546
+ this.logger.info('Created wiki-migrate WorkItem', {
547
+ workItemId: wi.id,
548
+ projectRoot,
549
+ proposedCount,
550
+ target: this.targetAgent,
551
+ });
552
+ }
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // Helpers
556
+ // ---------------------------------------------------------------------------
557
+ /**
558
+ * Best-effort label for a vault path that surfaces in UI/list views.
559
+ * Order matters: check global before the generic project tail.
560
+ */
561
+ export function describeVaultScope(vaultPath) {
562
+ if (vaultPath.endsWith('global-wiki'))
563
+ return 'global';
564
+ const homeTeams = path.join(os.homedir(), '.crewly/teams');
565
+ if (vaultPath.startsWith(homeTeams + path.sep)) {
566
+ const remainder = vaultPath.slice(homeTeams.length + 1);
567
+ const teamId = remainder.split(path.sep)[0] ?? '?';
568
+ return `team ${teamId}`;
569
+ }
570
+ const parent = path.dirname(path.dirname(vaultPath));
571
+ return `project ${path.basename(parent)}`;
572
+ }
573
+ function drainBrief(vaultPath, pendingCount) {
574
+ return [
575
+ '# Wiki Queue Drain',
576
+ '',
577
+ '**This is a bridge-auto maintenance WorkItem.** See the "Bridge-auto',
578
+ 'WorkItems" rule in your prompt — process via the skill, do not delete.',
579
+ '',
580
+ `**Vault:** \`${vaultPath}\``,
581
+ `**Pending items:** ${pendingCount}`,
582
+ '',
583
+ `**Partial progress is the norm** — drain whatever you can in this turn`,
584
+ `(5-20 items is a healthy chunk; you do not need to clear all ${pendingCount}).`,
585
+ 'When you mark this WI `done`, the bridge cooldown (30 min) expires and a',
586
+ 'fresh WI appears with whatever remains. Iterative drain over multiple',
587
+ 'sessions is the design.',
588
+ '',
589
+ '```bash',
590
+ `bash {{ORCHESTRATOR_SKILLS_PATH}}/wiki-process-queue/execute.sh --vault ${vaultPath}`,
591
+ '```',
592
+ '',
593
+ 'Each invocation claims one item, lets you read context, then commits via `POST /queue/<id>/process` (or `/skip`). Repeat 5-20 times per WI, then mark `done` via `complete-task` with a one-line summary of what you drained.',
594
+ ].join('\n');
595
+ }
596
+ function cleanupBrief(vaultPath, chunk, totalCandidates) {
597
+ const lines = [
598
+ '# Wiki Quality Cleanup',
599
+ '',
600
+ '**This is a bridge-auto maintenance WorkItem.** See the "Bridge-auto',
601
+ 'WorkItems" rule in your prompt — process via the skill, do not delete.',
602
+ '',
603
+ `**Vault:** \`${vaultPath}\``,
604
+ `**Candidates in this WI:** ${chunk.length}`,
605
+ `**Total candidates in vault:** ${totalCandidates}`,
606
+ '',
607
+ `**Partial progress is the norm** — review the ${chunk.length} pages in this WI, decide keep/delete for each, then call \`wiki-cleanup --apply --pages "..."\` with ONLY the relPaths you decided to delete. The next bridge tick (30 min cooldown) creates a fresh WI with the next ${chunk.length}. No need to drain everything in one session.`,
608
+ '',
609
+ '## Candidates',
610
+ '',
611
+ ];
612
+ for (const c of chunk) {
613
+ const conf = c.confidence === null ? 'unknown' : c.confidence.toFixed(2);
614
+ lines.push(`- \`${c.relPath}\` (confidence=${conf}, ${c.bytes} bytes) — ${c.reasons.join('; ')}`);
615
+ }
616
+ lines.push('', '## Workflow', '', '1. For each candidate, run `wiki-page-read` (or grep the file directly) to check whether it has any real value despite the flag. A `confidence=0.3` page might still be the only documentation of a critical gotcha.', '2. Build a comma-separated list of relPaths you want to delete.', '3. Run apply:', '', '```bash', `bash {{ORCHESTRATOR_SKILLS_PATH}}/wiki-cleanup/execute.sh --vault ${vaultPath} --apply --pages "<rel1>,<rel2>,..."`, '```', '', '4. Archive lives at `<vault>/.wiki-cleanup-archive.json` — every deleted page\'s body + frontmatter is preserved, so cleanup is reversible.', '5. Mark this WI done via `complete-task` with a one-line summary (`deleted N of ' + chunk.length + ', kept M, archive grew by ...`).');
617
+ return lines.join('\n');
618
+ }
619
+ function migrateBrief(projectRoot, proposedCount) {
620
+ return [
621
+ '# Wiki Legacy Migration',
622
+ '',
623
+ '**This is a bridge-auto maintenance WorkItem.** See the "Bridge-auto',
624
+ 'WorkItems" rule in your prompt — process via the skill, do not delete.',
625
+ '',
626
+ `**Project root:** \`${projectRoot}\``,
627
+ `**Proposed new pages:** ${proposedCount}`,
628
+ '',
629
+ `**Partial progress is the norm** — ${proposedCount} sounds like a lot,`,
630
+ 'but the migrate service is idempotent: every successful page lands in',
631
+ 'the manifest and is skipped on the next run. So you can run `--apply`,',
632
+ 'let it work for a few minutes, mark this WI `done` whatever it migrated,',
633
+ 'and the next bridge tick (30 min cooldown) creates a fresh WI for what',
634
+ 'remains. No need to wait for the whole batch to complete.',
635
+ '',
636
+ '1. Dry-run first to sanity-check the proposal:',
637
+ '```bash',
638
+ `bash {{ORCHESTRATOR_SKILLS_PATH}}/wiki-migrate/execute.sh --project-root ${projectRoot}`,
639
+ '```',
640
+ '2. Apply (will resume from the manifest on subsequent runs):',
641
+ '```bash',
642
+ `bash {{ORCHESTRATOR_SKILLS_PATH}}/wiki-migrate/execute.sh --project-root ${projectRoot} --apply`,
643
+ '```',
644
+ '3. Report `applied`/`skipped` counts in `complete-task` output and mark `done`. The bridge will detect remaining items on its next tick.',
645
+ ].join('\n');
646
+ }
647
+ /**
648
+ * Default project-root discovery used by the production bridge. Mirrors
649
+ * `discoverWikiVaults`'s project sources but returns roots, not vault paths.
650
+ */
651
+ export async function defaultProjectRoots() {
652
+ const roots = [];
653
+ const cwd = process.cwd();
654
+ if (existsSync(path.join(cwd, '.crewly')))
655
+ roots.push(cwd);
656
+ const projectsJsonPath = path.join(os.homedir(), '.crewly/projects.json');
657
+ if (existsSync(projectsJsonPath)) {
658
+ try {
659
+ const raw = await fs.readFile(projectsJsonPath, 'utf8');
660
+ const parsed = JSON.parse(raw);
661
+ if (Array.isArray(parsed)) {
662
+ for (const entry of parsed) {
663
+ if (entry && typeof entry.path === 'string' && path.isAbsolute(entry.path)) {
664
+ roots.push(entry.path);
665
+ }
666
+ }
667
+ }
668
+ }
669
+ catch {
670
+ // malformed projects.json — partial discovery is fine
671
+ }
672
+ }
673
+ return Array.from(new Set(roots));
674
+ }
675
+ //# sourceMappingURL=wiki-workitem-bridge.service.js.map