@worca/app 0.0.1

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 (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,302 @@
1
+ // src/core/channels.mjs
2
+ // Typed channel vocabulary + the dispatch-time binder. Pure: the only IO is the
3
+ // path STRINGS it computes via artifacts.mjs (no reads/writes). Replaces the
4
+ // role-coupled _nodeIo/_afterNode switch with data-driven channel wiring.
5
+
6
+ import { join } from 'node:path';
7
+ import { planPath, reviewPath } from './artifacts.mjs';
8
+
9
+ /** The closed set of channel ids (single source; imported by registry + validator).
10
+ * `workspace` (M3) is a read-only metadata channel carrying the frozen workspace
11
+ * description + member set; it is seeded once and never re-published (CONV-6). */
12
+ export const CHANNEL_IDS = ['userPrompt', 'plan', 'review', 'checklist', 'code', 'workspace', 'clarify', 'decomposition'];
13
+
14
+ /** Channels the dispatcher pre-seeds WITH VALUES on the bus at run start
15
+ * (orchestrator.mjs _dispatch bus literal): userPrompt IS the prompt,
16
+ * plan/checklist get default destinations, code is the standing worktree.
17
+ * The bus literal also carries workspace (metadata or null) and clarify/
18
+ * decomposition seeded as null — those are deliberately NOT listed here so the
19
+ * validator keeps warning when they are consumed with no upstream producer
20
+ * (current behavior). Single source for the validator's reachability pass —
21
+ * keep in sync with the _dispatch bus literal. */
22
+ export const PRESEEDED_CHANNELS = ['userPrompt', 'plan', 'checklist', 'code'];
23
+
24
+ /**
25
+ * Mint the concrete OUTPUT handle for a channel the node produces. This is the
26
+ * relocated output half of the old _nodeIo switch; paths are byte-identical so
27
+ * history/state.json/MOCK markers do not move.
28
+ * @param {string} channel
29
+ * @param {{projectDir,pipelineDir,baseName,datePrefix,cycle,key?}} ctx
30
+ */
31
+ export function allocate(channel, ctx) {
32
+ const { projectDir, pipelineDir, baseName, datePrefix, cycle, key, workspaceKey } = ctx;
33
+ switch (channel) {
34
+ case 'plan': {
35
+ // The planner is cycle-aware so a plan-review -> planner loop writes a fresh
36
+ // -vN on each replan instead of clobbering v1 (cycle is 1 on the first pass,
37
+ // so v1 has NO -v suffix, matching today's seeded io.planPath). The refiner
38
+ // versions up to cycle+1 (a self-loop never produces v1). workspaceKey (when
39
+ // present) routes the unified plan to the workspace store; absent it is byte-
40
+ // identical to the single-project path.
41
+ const version = key === 'planner' ? cycle : cycle + 1;
42
+ return { kind: 'artifact', path: planPath(projectDir, baseName, version, datePrefix, workspaceKey) };
43
+ }
44
+ case 'review': {
45
+ // Review json basename differs by producing role (keeps existing filenames).
46
+ // A CUSTOM verifier key gets its own `<key>-review` basename so it can never
47
+ // clobber the built-in reviewer's impl-review files.
48
+ const k = key || 'reviewer';
49
+ const BESPOKE_BASE = {
50
+ reviewer: 'impl-review',
51
+ refiner: 'refine-review',
52
+ manualWebUiTesting: 'webui-review',
53
+ planReviewer: 'plan-review',
54
+ workspaceReviewer: 'ws-review',
55
+ };
56
+ let base = BESPOKE_BASE[k] || `${k}-review`;
57
+ // A custom key like "impl" would mint impl-review-* and clobber the built-in
58
+ // reviewer's files AND its DB verdict (reviewKind upsert); divert it.
59
+ if (!BESPOKE_BASE[k] && Object.values(BESPOKE_BASE).includes(base)) base = `${k}-agent-review`;
60
+ // ▲ C2: the refiner emits ONLY a json verdict (its md is null => private to its
61
+ // self-loop). Every other verifier carries a review md so publish() folds it
62
+ // onto the shared `review` channel its loop target consumes. workspaceKey routes
63
+ // the review md to the workspace store (the json stays per-cycle in the pipeline
64
+ // dir, store-root independent); absent it is byte-identical. Custom verifiers
65
+ // keep their md pipeline-dir-local (like webui-review).
66
+ const mdPath = k === 'refiner'
67
+ ? null
68
+ : k === 'manualWebUiTesting'
69
+ ? join(pipelineDir, `webui-review-cycle${cycle}.md`)
70
+ : k === 'planReviewer'
71
+ ? reviewPath(projectDir, baseName, datePrefix, 'plan-review', workspaceKey)
72
+ : k === 'workspaceReviewer'
73
+ ? reviewPath(projectDir, baseName, datePrefix, 'ws-review', workspaceKey)
74
+ : k === 'reviewer'
75
+ ? reviewPath(projectDir, baseName, datePrefix, 'impl-review', workspaceKey)
76
+ : join(pipelineDir, `${base}-cycle${cycle}.md`);
77
+ return { kind: 'review', mdPath, jsonPath: join(pipelineDir, `${base}-cycle${cycle}.json`), reviewKind: base };
78
+ }
79
+ case 'checklist':
80
+ return { kind: 'artifact', path: join(pipelineDir, 'manual-tests-checklist.md') };
81
+ case 'code':
82
+ return { kind: 'worktree' }; // standing channel; nothing to allocate
83
+ case 'workspace':
84
+ // Read-only metadata handle: the frozen workspace description snapshot. The
85
+ // member set + description are seeded onto the bus directly by the orchestrator
86
+ // (this just names the on-disk path of the frozen snapshot).
87
+ return { kind: 'metadata', path: join(pipelineDir, 'workspace-description.md') };
88
+ case 'decomposition':
89
+ // The decomposer writes decomposition.json (phase->task index) into the run dir,
90
+ // alongside a tasks/ subdir of self-contained task markdown files.
91
+ return { kind: 'artifact', path: join(pipelineDir, 'decomposition.json') };
92
+ case 'clarify':
93
+ // The clarify agent writes clarify.json into the pipeline dir as scratch; the
94
+ // DB clarify row is authoritative. Same path the legacy pre-step used.
95
+ return { kind: 'clarify', path: join(pipelineDir, 'clarify.json') };
96
+ default: {
97
+ // Open vocabulary: any custom channel allocates a generic pipeline-dir
98
+ // artifact. A registry channelDef (threaded via ctx.channelDefs) overrides
99
+ // kind (md|json) and filename; absent a def the channel defaults to
100
+ // markdown <id>.md. Loop re-runs suffix -cycle<N> so a rewind never
101
+ // clobbers an earlier cycle's output.
102
+ const def = (ctx.channelDefs && ctx.channelDefs[channel]) || null;
103
+ const ext = def?.kind === 'json' ? 'json' : 'md';
104
+ const stem = String(def?.filename || `${channel}.${ext}`).replace(/\.(md|json)$/i, '');
105
+ const name = Number(cycle) > 1 ? `${stem}-cycle${cycle}.${ext}` : `${stem}.${ext}`;
106
+ return { kind: 'artifact', path: join(pipelineDir, name), channel };
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Resolve a node's inputs from the bus (latest-writer-wins). Required channels that
113
+ * are null still pass through (the agent/mock proceeds, e.g. a pre-seeded but
114
+ * unwritten plan path). Optional channels that are null are omitted entirely.
115
+ */
116
+ export function bindInputs(consumes, optionalConsumes, bus) {
117
+ const optional = new Set(optionalConsumes || []);
118
+ const inputs = {};
119
+ for (const c of consumes || []) {
120
+ const v = bus[c];
121
+ if (v == null && optional.has(c)) continue; // omit absent optional channel
122
+ inputs[c] = v;
123
+ }
124
+ return inputs;
125
+ }
126
+
127
+ /**
128
+ * Fold a node's result back onto the bus for each produced channel, in node order.
129
+ * Clearing `review` on a `code` publish fixes the sticky fix-mode bug: a later
130
+ * implementer no longer sees a stale review.
131
+ */
132
+ export function publish(produces, result, outputs, bus) {
133
+ if (!result) return;
134
+ for (const c of produces || []) {
135
+ if (c === 'plan') {
136
+ const path = result.outPlanPath || result.planPath || outputs.plan?.path;
137
+ if (path) bus.plan = { kind: 'artifact', path };
138
+ } else if (c === 'review') {
139
+ // ▲ C2: only a review carrying an md (reviewer / manualWebUiTesting) is shared.
140
+ // The refiner's md-less verdict stays private to its self-loop (read from the
141
+ // results array by _reviewOf, never the bus), so it can't flip a later
142
+ // implementer into `fix` mode.
143
+ const mdPath = result.reviewMdPath ?? outputs.review?.mdPath;
144
+ if (!mdPath) continue;
145
+ bus.review = { kind: 'review', mdPath, jsonPath: outputs.review?.jsonPath, verdict: result.review, reviewKind: outputs.review?.reviewKind };
146
+ } else if (c === 'checklist') {
147
+ const path = result.checklistPath || outputs.checklist?.path;
148
+ if (path) bus.checklist = { kind: 'artifact', path };
149
+ } else if (c === 'code') {
150
+ bus.review = null; // implementer superseded any pending review (fix-mode reset)
151
+ } else if (c === 'workspace') {
152
+ // Read-only metadata: seeded once by the orchestrator, NEVER re-published
153
+ // (CONV-6: the frozen per-step snapshot must not change mid-run). No-op.
154
+ } else if (c === 'clarify') {
155
+ // The clarify node carries questions (from the agent) + answers (from the
156
+ // interactive gate) in its result; fold both onto the bus so the planner reads
157
+ // inputs.clarify.answers.
158
+ bus.clarify = {
159
+ kind: 'clarify',
160
+ path: outputs.clarify?.path,
161
+ questions: result.questions || [],
162
+ answers: result.answers || [],
163
+ };
164
+ } else if (c === 'decomposition') {
165
+ // Fold the decomposer's parsed phases onto the bus so _runStep's implement
166
+ // step can fan out one implementer per task.
167
+ const path = result.decompositionPath || outputs.decomposition?.path;
168
+ bus.decomposition = {
169
+ kind: 'artifact',
170
+ path: path || null,
171
+ phases: Array.isArray(result.decomposition?.phases) ? result.decomposition.phases : [],
172
+ };
173
+ } else {
174
+ // Open vocabulary: a custom channel folds its allocated artifact handle onto
175
+ // the bus (latest-writer-wins), mirroring plan/checklist.
176
+ const path = outputs?.[c]?.path;
177
+ if (path) bus[c] = { kind: 'artifact', path };
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Adapter: flatten typed inputs/outputs into the EXACT field names the phases.mjs
184
+ * runners already read (verified against runners.mjs), so runners.mjs/phases.mjs and
185
+ * the runner/dispatcher tests need ZERO changes. This is one of two role switches
186
+ * (the other is runners.mjs's producer/verifier); it names fields, it does not
187
+ * compute data flow.
188
+ */
189
+ export function legacyFields(node, inputs, outputs, cycle, baseName) {
190
+ const fields = legacyRoleFields(node, inputs, outputs, cycle, baseName);
191
+ // Workspace runs: surface the read-only metadata channel (description + member
192
+ // set) onto the flattened ctx so phases.mjs runners read ctx.workspace. Absent
193
+ // the channel (every single-project node) the field is never added, so the ctx
194
+ // shape stays byte-identical.
195
+ if (inputs && inputs.workspace) fields.workspace = inputs.workspace;
196
+ return fields;
197
+ }
198
+
199
+ /** Per-role field naming (the original legacyFields body). */
200
+ function legacyRoleFields(node, inputs, outputs, cycle, baseName) {
201
+ switch (node.key) {
202
+ case 'planner':
203
+ // reviewPath is set only when a review is bound (a plan-review -> planner
204
+ // rewind), which switches runPlannerPlan into its cold-replan branch.
205
+ return { planFilePath: outputs.plan?.path, baseName, answers: inputs.clarify?.answers || inputs.userPrompt?.answers || [], reviewPath: inputs.review?.mdPath };
206
+ case 'refiner':
207
+ return { inPlanPath: inputs.plan?.path, outPlanPath: outputs.plan?.path, reviewJsonPath: outputs.review?.jsonPath, cycle };
208
+ case 'decomposer':
209
+ return { planPath: inputs.plan?.path, decompositionPath: outputs.decomposition?.path };
210
+ case 'implementer':
211
+ // The implementer fixes CODE reviews (impl-review / webui-review). A plan-review
212
+ // left on the shared `review` bus by a planReviewer -> planner loop is NOT for the
213
+ // implementer (it bounces to the planner), so it must not flip a first-pass
214
+ // implementer into fix mode. Discriminate by review provenance (reviewKind), not
215
+ // cycle — a linear `... -> reviewer -> implementer` forward edge legitimately fixes
216
+ // a code review at cycle 1 (see saved-pipeline-parity).
217
+ return { planPath: inputs.plan?.path, reviewPath: inputs.review?.mdPath, mode: (inputs.review?.mdPath && inputs.review?.reviewKind !== 'plan-review') ? 'fix' : 'implement', cycle };
218
+ case 'planReviewer':
219
+ return { planPath: inputs.plan?.path, reviewMdPath: outputs.review?.mdPath, reviewJsonPath: outputs.review?.jsonPath, cycle };
220
+ case 'reviewer':
221
+ return { planPath: inputs.plan?.path, reviewMdPath: outputs.review?.mdPath, reviewJsonPath: outputs.review?.jsonPath, cycle };
222
+ case 'workspaceReviewer':
223
+ // Identical field shape to `reviewer` — the workspace synthesizer reads the
224
+ // same planPath/reviewMd/reviewJson/cycle and folds its merged verdict onto
225
+ // the shared `review` channel its implementer loop target consumes.
226
+ return { planPath: inputs.plan?.path, reviewMdPath: outputs.review?.mdPath, reviewJsonPath: outputs.review?.jsonPath, cycle };
227
+ case 'manualTestsChecklist':
228
+ return { planPath: inputs.plan?.path, checklistPath: outputs.checklist?.path };
229
+ case 'manualWebUiTesting':
230
+ return { checklistPath: inputs.checklist?.path, reviewMdPath: outputs.review?.mdPath, reviewJsonPath: outputs.review?.jsonPath, cycle };
231
+ default:
232
+ // Generic agents: no bespoke field names. The generic runners consume the
233
+ // typed inputs/outputs maps directly (same references _bindNodeIo spreads).
234
+ return { cycle, inputs, outputs };
235
+ }
236
+ }
237
+
238
+ /** Channels materializable from plain prompt text (markdown artifacts). userPrompt
239
+ * is already the prompt; code is the standing worktree; review is not pre-seeded and
240
+ * is optional for its only consumer — so none of those ever seed. */
241
+ const SEEDABLE = new Set(['plan', 'checklist']);
242
+
243
+ /**
244
+ * Decide which materializable channels must be seeded from the user prompt because
245
+ * the topology REQUIRES them before any step produces them (a pipeline that starts
246
+ * mid-stream — implementer/refiner/... first). Mirrors the validator's step-ordered
247
+ * reachability walk (workflow-validator.mjs:112-144) but returns the channels to fill
248
+ * rather than warnings. Reads the channel spec carried on each resolved node, so no
249
+ * registry argument is needed (resolveWorkflow already stamped consumes/produces).
250
+ * @param {Array<Array<{key,consumes,optionalConsumes,produces}>>} steps
251
+ * @returns {string[]} channel ids to seed (subset of SEEDABLE), in first-needed order
252
+ */
253
+ export function entrySeedChannels(steps) {
254
+ const seeded = new Set();
255
+ const produced = new Set();
256
+ for (const group of steps || []) {
257
+ for (const node of group || []) {
258
+ const optional = new Set(node?.optionalConsumes || []);
259
+ for (const c of node?.consumes || []) {
260
+ if (!SEEDABLE.has(c) || optional.has(c) || produced.has(c) || seeded.has(c)) continue;
261
+ seeded.add(c);
262
+ }
263
+ }
264
+ // Producers fold in AFTER the step (matches the validator's frozen-snapshot model),
265
+ // so a node that both consumes AND produces the same channel (the refiner) still
266
+ // triggers a seed at its own step.
267
+ for (const node of group || []) for (const c of node?.produces || []) produced.add(c);
268
+ }
269
+ return [...seeded];
270
+ }
271
+
272
+ /**
273
+ * Render the `## Attached files` block listing each attachment by path + name.
274
+ * Single source of truth shared by renderPromptArtifact (the seeded file body) and
275
+ * phases.mjs taskHeader (the entry agent's inline header) so the two cannot drift.
276
+ * Returns '' when there are no attachments.
277
+ * @param {Array<{name:string,path:string}>} [extras]
278
+ */
279
+ export function renderAttachmentsBlock(extras = []) {
280
+ if (!Array.isArray(extras) || extras.length === 0) return '';
281
+ return (
282
+ `\n## Attached files\n\nThe user attached these files; read any that are relevant:\n\n` +
283
+ extras.map((e) => `- \`${e.path}\` (${e.name})`).join('\n') +
284
+ '\n'
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Render the markdown a seeded artifact channel holds: the user's request stands in
290
+ * for the missing upstream artifact, with any attached files listed by path.
291
+ * @param {string} promptText
292
+ * @param {Array<{name:string,path:string}>} [extras]
293
+ */
294
+ export function renderPromptArtifact(promptText, extras = []) {
295
+ const body = (promptText || '').trim() || '(no prompt text)';
296
+ return (
297
+ `# Task (from the user prompt)\n\n` +
298
+ `No upstream agent produced this artifact, so the user's request below stands in for it.\n\n` +
299
+ `## Original request\n\n${body}\n` +
300
+ renderAttachmentsBlock(extras)
301
+ );
302
+ }
@@ -0,0 +1,27 @@
1
+ // src/core/chat/allowlist.mjs
2
+ // Inbound chat-ID gate, ported from pre-1.0 integrations/allowlist.js.
3
+ // DENY BY DEFAULT: an empty allowlist admits nobody — configuring a bot token
4
+ // without allowedChatIds gives outbound notifications but zero inbound control
5
+ // (chat-connectivity-design.md §4.6).
6
+
7
+ /**
8
+ * @param {string[]} allowedIds chat IDs permitted to send inbound commands
9
+ * @param {{ debug?: (...args: unknown[]) => void }} [log]
10
+ */
11
+ export function createAllowlistGuard(allowedIds, log = {}) {
12
+ const set = new Set((allowedIds || []).map((s) => String(s).trim()).filter(Boolean));
13
+ const debug = log.debug ?? (() => {});
14
+
15
+ return {
16
+ isAllowed({ platform, chatId }) {
17
+ if (set.has(String(chatId))) return true;
18
+ debug(`[allowlist] drop inbound message — platform=${platform} chatId=${chatId} not in allowlist`);
19
+ return false;
20
+ },
21
+ };
22
+ }
23
+
24
+ /** Comma-separated config value -> id list ("123, 456" -> ['123','456']). */
25
+ export function parseIdList(value) {
26
+ return String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
27
+ }