@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
@@ -25,6 +25,12 @@ import { buildAskSpawnOptions, buildMcpConfig, ASK_MCP_SERVER_PATH } from './spa
25
25
  import { refreshAskMemoryMount } from './memory-deps.mjs';
26
26
  import { validateProposal } from './proposal.mjs';
27
27
  import { validateMetricsChange } from './metrics-deps.mjs';
28
+ import { validatePolicyChange } from './policy-deps.mjs';
29
+ import { validateModelChange } from './model-deps.mjs';
30
+ import { validateScheduleChange } from './schedule-deps.mjs';
31
+ import { lookupTask } from './source-deps.mjs';
32
+ import { effectiveTimeZone } from './schedule-spec.mjs';
33
+ import { scheduleDefaults } from '../settings.mjs';
28
34
  import { revalidateWorkflowProposal } from './workflow-deps.mjs';
29
35
  import { askLimits, ASK_LIMITS } from './limits.mjs';
30
36
  import {
@@ -46,6 +52,7 @@ class AskTurn extends EventEmitter {
46
52
  mock = null, attachmentNames = {},
47
53
  pinnedScope = null,
48
54
  memoryProject = null,
55
+ timeZone = null,
49
56
  deps = {},
50
57
  } = {}) {
51
58
  super();
@@ -65,6 +72,8 @@ class AskTurn extends EventEmitter {
65
72
  this.attachmentNames = attachmentNames || {};
66
73
  // #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
67
74
  this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
75
+ // The user's timezone (the browser's, validated) — the zone a proposal's when / every is read in.
76
+ this.timeZone = effectiveTimeZone(timeZone);
68
77
  // Native-rules revision (D16): {key, name}|null — the scope set this turn mounts through --add-dir.
69
78
  this.memoryProject = memoryProject && typeof memoryProject === 'object' ? memoryProject : null;
70
79
  this.memoryDir = null;
@@ -81,6 +90,12 @@ class AskTurn extends EventEmitter {
81
90
  validateProposal: deps.validateProposal ?? validateProposal,
82
91
  revalidateWorkflow: deps.revalidateWorkflow ?? revalidateWorkflowProposal,
83
92
  validateMetricsChange: deps.validateMetricsChange ?? validateMetricsChange,
93
+ validatePolicyChange: deps.validatePolicyChange ?? validatePolicyChange,
94
+ validateScheduleChange: deps.validateScheduleChange ?? validateScheduleChange,
95
+ validateModelChange: deps.validateModelChange ?? validateModelChange,
96
+ scheduleDefaults: deps.scheduleDefaults ?? scheduleDefaults,
97
+ // A proposed plugin task is looked up here, once: it must exist, and the card shows its title.
98
+ lookupTask: deps.lookupTask === undefined ? lookupTask : deps.lookupTask,
84
99
  trackRun: deps.trackRun ?? null,
85
100
  generateTitle: deps.generateTitle ?? generateTitle,
86
101
  askLimits: deps.askLimits ?? askLimits,
@@ -104,6 +119,8 @@ class AskTurn extends EventEmitter {
104
119
  onCommentMutation: deps.onCommentMutation ?? (() => {}),
105
120
  onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
106
121
  onMemoryMutation: deps.onMemoryMutation ?? (() => {}),
122
+ onScriptMutation: deps.onScriptMutation ?? (() => {}),
123
+ onScheduleMutation: deps.onScheduleMutation ?? (() => {}),
107
124
  // DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
108
125
  // liveCostRates: override → list price → null). Injectable so tests pin
109
126
  // the frame arithmetic without the catalog.
@@ -164,7 +181,9 @@ class AskTurn extends EventEmitter {
164
181
  // to real rows (spec §6.4). A ledger failure never blocks the card.
165
182
  let attachments = [];
166
183
  try { attachments = (typeof d.store.listAttachments === 'function' && d.store.listAttachments(this.threadId)) || []; } catch { attachments = []; }
167
- const r = await d.validateProposal(inp, { cardId, attachments });
184
+ let defaults = {};
185
+ try { defaults = d.scheduleDefaults() || {}; } catch { defaults = {}; }
186
+ const r = await d.validateProposal(inp, { cardId, attachments, timeZone: this.timeZone, nowMs: d.now(), scheduleDefaults: defaults, lookupTask: d.lookupTask });
168
187
  if (r && r.ok) {
169
188
  // #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
170
189
  // the pinned one is accepted but flagged — the card renders the mismatch
@@ -245,6 +264,86 @@ class AskTurn extends EventEmitter {
245
264
  this._persistBlocks();
246
265
  }
247
266
 
267
+ /**
268
+ * propose_policy_change RESULT: the metrics card's split — the child validated for the model, the parent re-validates
269
+ * the same INPUT over the real readers (policy-proposal.mjs) and mints the card.
270
+ */
271
+ async _onPolicyProposal(input, text, isError) {
272
+ if (isError) return;
273
+ let out = null;
274
+ try { out = JSON.parse(text); } catch { out = null; }
275
+ if (!out || out.ok !== true) return;
276
+ const d = this.deps;
277
+ const raw = input && typeof input === 'object' ? input : {};
278
+ // The child's pinned-scope default, replayed (tools.mjs fillPolicyPin): the card matches what the model saw.
279
+ const pin = this.pinnedScope;
280
+ const kind = typeof raw.kind === 'string' ? raw.kind.trim() : '';
281
+ let inp = raw;
282
+ if (pin && !(typeof raw.projectKey === 'string' && raw.projectKey.trim()) && !(typeof raw.workspaceId === 'string' && raw.workspaceId.trim())) {
283
+ if (pin.projectKey && (kind === 'enable' || kind === 'edit')) inp = { ...raw, projectKey: pin.projectKey };
284
+ if (pin.workspaceId && (kind === 'edit' || kind === 'workspace_home' || kind === 'route_members')) inp = { ...raw, workspaceId: pin.workspaceId };
285
+ }
286
+ try {
287
+ const r = await d.validatePolicyChange(inp);
288
+ if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
289
+ else {
290
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
291
+ this.reducer.addBlock({ kind: 'notice', text: `Policy change rejected: ${errors.join('; ')}` });
292
+ }
293
+ } catch (err) {
294
+ this.reducer.addBlock({ kind: 'notice', text: `Policy change rejected: ${err?.message || err}` });
295
+ }
296
+ this._persistBlocks();
297
+ }
298
+
299
+ /**
300
+ * propose_schedule_change RESULT: the metrics card's split — the child validated for the model, the parent
301
+ * re-validates the same INPUT against the live rows and mints the card. A child {ok:false} already reached
302
+ * the model as text: no card, no notice.
303
+ */
304
+ async _onScheduleProposal(input, text, isError) {
305
+ if (isError) return;
306
+ let out = null;
307
+ try { out = JSON.parse(text); } catch { out = null; }
308
+ if (!out || out.ok !== true) return;
309
+ const d = this.deps;
310
+ try {
311
+ const r = await d.validateScheduleChange(input && typeof input === 'object' ? input : {}, { timeZone: this.timeZone });
312
+ if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
313
+ else {
314
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
315
+ this.reducer.addBlock({ kind: 'notice', text: `Schedule change rejected: ${errors.join('; ')}` });
316
+ }
317
+ } catch (err) {
318
+ this.reducer.addBlock({ kind: 'notice', text: `Schedule change rejected: ${err?.message || err}` });
319
+ }
320
+ this._persistBlocks();
321
+ }
322
+
323
+ /**
324
+ * propose_model_change RESULT: the metrics card's split — the child validated for the model, the parent
325
+ * re-validates the same INPUT over the real catalog and providers and mints the card. A child {ok:false}
326
+ * already reached the model as text: no card, no notice.
327
+ */
328
+ async _onModelProposal(input, text, isError) {
329
+ if (isError) return;
330
+ let out = null;
331
+ try { out = JSON.parse(text); } catch { out = null; }
332
+ if (!out || out.ok !== true) return;
333
+ const d = this.deps;
334
+ try {
335
+ const r = await d.validateModelChange(input && typeof input === 'object' ? input : {});
336
+ if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
337
+ else {
338
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
339
+ this.reducer.addBlock({ kind: 'notice', text: `Model change rejected: ${errors.join('; ')}` });
340
+ }
341
+ } catch (err) {
342
+ this.reducer.addBlock({ kind: 'notice', text: `Model change rejected: ${err?.message || err}` });
343
+ }
344
+ this._persistBlocks();
345
+ }
346
+
248
347
  /** The card exists from the tool_use on (spec §8.2, PD7): a building block with the four-step trace, persisted. */
249
348
  _onWorkflowStart(toolUseId, input) {
250
349
  const d = this.deps;
@@ -329,6 +428,11 @@ class AskTurn extends EventEmitter {
329
428
  onWorkflowResult: ({ toolUseId, text, isError }) => this._onWorkflowResult(toolUseId, text, isError), // the hook's `input` is not needed here: the card is rebuilt from `out`
330
429
  onTrackRun: ({ input, isError }) => this._onTrackRun(input, isError),
331
430
  onMetricsProposal: ({ input, text, isError }) => this._onMetricsProposal(input, text, isError),
431
+ onPolicyProposal: ({ input, text, isError }) => this._onPolicyProposal(input, text, isError),
432
+ onScheduleProposal: ({ input, text, isError }) => this._onScheduleProposal(input, text, isError),
433
+ onModelProposal: ({ input, text, isError }) => this._onModelProposal(input, text, isError),
434
+ // pause / resume / skip / mark-read in the child → the server's schedules-changed frames.
435
+ onScheduleMutation: (e) => { try { this.deps.onScheduleMutation(e); } catch { /* a broken sink never breaks the turn */ } },
332
436
  // The MCP child cannot broadcast; the parent turns its comment writes into
333
437
  // the same poke the REST routes emit.
334
438
  onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
@@ -337,6 +441,8 @@ class AskTurn extends EventEmitter {
337
441
  onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
338
442
  // ...and for memory: a remember/forget in the child becomes the server's memory-changed frame.
339
443
  onMemoryMutation: (e) => { try { this.deps.onMemoryMutation(e); } catch { /* a broken sink never breaks the turn */ } },
444
+ // ...and for scripts: a save_script in the child becomes the server's scripts-changed frame.
445
+ onScriptMutation: (e) => { try { this.deps.onScriptMutation(e); } catch { /* a broken sink never breaks the turn */ } },
340
446
  // DISPLAY ONLY — never a sink input: prices the running usage sum (main +
341
447
  // sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
342
448
  // that approximation. _complete() reads summary.costUsd, not this.
@@ -485,7 +591,8 @@ class AskTurn extends EventEmitter {
485
591
  // the flag set (the awaiting continuation resumes a microtask later);
486
592
  // flag-first is kept as defensive style (plugin-shim.mjs:164 precedent).
487
593
  timer = d.setTimeout(() => { this.timedOut = true; try { this.abort.abort(); } catch { /* ignore */ } }, d.limits.turnTimeoutMs);
488
- const limitsNow = d.askLimits(); // D12: read fresh every turn
594
+ // D12: read fresh every turn. The pinned project's team policy may start the limits off (team-policy §5).
595
+ const limitsNow = d.askLimits({ projectKey: this.pinnedScope?.projectKey || null });
489
596
  out = await this._attempts(limitsNow, mcpConfigPath, scratchDir);
490
597
  } catch (err) {
491
598
  // Backstop for a deps failure (mkdir/write) — _attempts itself never throws.
@@ -0,0 +1,406 @@
1
+ // src/core/ask-files.mjs
2
+ // Preview files for an ask form (spec §7). Agents reference files by a
3
+ // RUN-RELATIVE path; the host resolves, sniffs, caps and SNAPSHOTS them at ask
4
+ // time, then serves them by (runId, askId, index). Nothing downstream ever takes
5
+ // a path from the agent or from HTTP.
6
+ //
7
+ // Three refusals, in order, and each is a gate-2 error the agent is resumed with:
8
+ // 1. SHAPE — refusePathShape, pure, platform-independent (a run authored on
9
+ // Linux is read back on Windows, so every Windows rule applies
10
+ // everywhere). Only the case-FOLD of the containment comparison
11
+ // is platform-conditional, and `platform` is injectable.
12
+ // 2. CONTAINMENT — realpath(candidate) must sit inside realpath(one root); a
13
+ // symlink out of the tree therefore refuses itself.
14
+ // 3. KIND — magic bytes decide. The extension and the agent's claim are
15
+ // IGNORED (§7). Text has no magic, so a UTF-8-clean body is
16
+ // probed by content: xml/svg, JSON, a diff header, any other
17
+ // leading '<' is markup and REFUSED, else text/plain.
18
+ //
19
+ // The allowlist below IS the §7 trust table. It is deliberately NOT
20
+ // src/core/ask/attachment-kind.mjs: that is the chat-attachment policy (no SVG,
21
+ // no media, no diff) and the two must be able to diverge.
22
+ //
23
+ // CROSS-PLATFORM CONTRACT
24
+ // · Every path SHAPE rule is enforced on every platform: a run authored on
25
+ // Linux is read back on Windows, and the snapshot dir must be portable.
26
+ // `test/ask-files.test.mjs` therefore covers CON / COM1 / `C:x` / UNC /
27
+ // `<>:"|?*` / trailing dot / trailing space from a macOS or Linux host.
28
+ // · Only the case-FOLD of the containment comparison is platform-conditional,
29
+ // through the injectable `platform` option (win32 + darwin fold, linux does
30
+ // not). Both arms are exercised on one host.
31
+ // · Paths are built with path.join / path.resolve only — never a string '/'.
32
+ // `stored` is `<index><ext>`, which is a legal basename on every platform.
33
+ // · Snapshot dirs are removed with the pipeline dir; nothing here unlinks.
34
+ import { createHash } from 'node:crypto';
35
+ import { createReadStream } from 'node:fs';
36
+ import { copyFile, lstat, mkdir, open, readFile, realpath, writeFile } from 'node:fs/promises';
37
+ import { join, resolve, sep } from 'node:path';
38
+ import process from 'node:process';
39
+
40
+ import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
41
+
42
+ /** §7's trust table: sniffed mime -> { ext, trust }. `trust` is what the P3
43
+ * renderer switches on; the ext is the on-disk suffix of the snapshot. */
44
+ export const ASK_FILE_MIMES = Object.freeze({
45
+ 'image/png': { ext: '.png', trust: 'inline' },
46
+ 'image/jpeg': { ext: '.jpg', trust: 'inline' },
47
+ 'image/gif': { ext: '.gif', trust: 'inline' },
48
+ 'image/webp': { ext: '.webp', trust: 'inline' },
49
+ 'image/avif': { ext: '.avif', trust: 'inline' },
50
+ 'image/svg+xml': { ext: '.svg', trust: 'inert' },
51
+ 'text/plain': { ext: '.txt', trust: 'text' },
52
+ 'application/json':{ ext: '.json', trust: 'text' },
53
+ 'text/x-diff': { ext: '.diff', trust: 'text' },
54
+ 'application/pdf': { ext: '.pdf', trust: 'viewer' },
55
+ 'video/mp4': { ext: '.mp4', trust: 'media' },
56
+ 'video/webm': { ext: '.webm', trust: 'media' },
57
+ 'audio/mpeg': { ext: '.mp3', trust: 'media' },
58
+ 'audio/wav': { ext: '.wav', trust: 'media' },
59
+ 'audio/ogg': { ext: '.ogg', trust: 'media' },
60
+ });
61
+
62
+ /** The basename a snapshot may have — the ONLY string the file route ever joins
63
+ * onto a directory. `<index><ext>`, nothing else, ever. */
64
+ const STORED_RE = /^\d{1,2}\.[a-z0-9]{1,5}$/;
65
+
66
+ /** ISO 32000-1 note 13: `%PDF-` may sit behind up to 1024 bytes of preamble. */
67
+ const PDF_HEADER_WINDOW = 1024;
68
+ /** Magic-byte window; also the head we decode for the text probes' cheap path. */
69
+ const SNIFF_HEAD = 4096;
70
+ /** A body with no binary magic bigger than this is refused by SIZE (`too-big`,
71
+ * naming this cap) rather than read whole into memory to be proved text. */
72
+ const TEXT_SNIFF_MAX = 1024 * 1024;
73
+ /** sniffFile's answer for such a body: the caller turns it into a size refusal
74
+ * that names the cap, so the agent learns to pick a smaller file — not "not a
75
+ * file type worca can display", which sent it back with the same file. */
76
+ const TEXT_TOO_BIG = Symbol('text-too-big');
77
+
78
+ /** MS-DOS device names: reserved in EVERY directory on Windows, with or without
79
+ * an extension, case-insensitively. */
80
+ const WIN_DEVICE_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
81
+ /** Characters Windows forbids in a path component (`:` included — it is also how
82
+ * a drive-relative path smuggles itself in). */
83
+ const WIN_RESERVED_CHARS_RE = /[<>:"|?*]/;
84
+ /** ISO-BMFF major brands that are MP4-family video. Everything else sharing the
85
+ * `ftyp` container (HEIC, QuickTime, M4A, 3GP) is refused as unrecognized. */
86
+ const MP4_BRANDS = new Set(['isom', 'iso2', 'iso3', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'mp71', 'avc1', 'dash', 'M4V ']);
87
+
88
+ /**
89
+ * Why this run-relative path is refused, or null when its SHAPE is acceptable.
90
+ * Pure, and platform-independent on purpose (E10): the snapshot must be
91
+ * reproducible on every host that later reads the run.
92
+ * @param {unknown} rel
93
+ * @returns {string|null}
94
+ */
95
+ export function refusePathShape(rel) {
96
+ if (typeof rel !== 'string' || !rel.trim()) return 'a file value must be a non-empty path';
97
+ // Windows forbids every C0 control and DEL in a name, and a newline in `rel` would
98
+ // split the one-line audit entry the refusal lands on — so the reason is JSON-quoted.
99
+ for (let i = 0; i < rel.length; i++) {
100
+ const k = rel.charCodeAt(i);
101
+ if (k < 32 || k === 127) return `${JSON.stringify(rel)} contains a control character`;
102
+ }
103
+ if (rel.length > 1024) return 'a file path may not exceed 1024 characters';
104
+ if (rel.startsWith('/') || rel.startsWith('\\')) return `"${rel}" is absolute; file values are run-relative`;
105
+ if (/^[A-Za-z]:/.test(rel)) return `"${rel}" names a drive; file values are run-relative`;
106
+ const parts = rel.split(/[\\/]+/);
107
+ for (const part of parts) {
108
+ if (!part || part === '.') continue;
109
+ if (part === '..') return `"${rel}" escapes the run with ".."`;
110
+ if (WIN_DEVICE_RE.test(part)) return `"${rel}" uses the reserved Windows device name "${part}"`;
111
+ if (WIN_RESERVED_CHARS_RE.test(part)) return `"${rel}" uses a character Windows forbids in a path`;
112
+ if (part.endsWith('.') || part.endsWith(' ')) return `"${rel}" has a segment ending in a dot or a space`;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Does the sniffed mime satisfy the field's `accept` patterns? An empty/absent
118
+ * accept means "anything on the allowlist". `text/plain` stands in for any
119
+ * `text/*` pattern, because markdown and csv are not separately sniffable (E9). */
120
+ export function mimeMatchesAccept(mime, accept) {
121
+ const list = Array.isArray(accept) ? accept.filter((a) => typeof a === 'string' && a.trim()) : [];
122
+ if (!list.length) return true;
123
+ const [type] = String(mime).split('/');
124
+ return list.some((raw) => {
125
+ const pattern = raw.trim().toLowerCase();
126
+ if (pattern === '*/*' || pattern === '*') return true;
127
+ if (pattern.endsWith('/*')) return type === pattern.slice(0, -2);
128
+ if (pattern === mime) return true;
129
+ return mime === 'text/plain' && pattern.startsWith('text/');
130
+ });
131
+ }
132
+
133
+ /**
134
+ * The real mime of a body, from its leading bytes, or null when it is not a type
135
+ * worca will display. The extension and whatever the agent claimed are ignored.
136
+ * @param {Buffer} buf
137
+ * @returns {string|null}
138
+ */
139
+ export function sniffMime(buf) {
140
+ if (!Buffer.isBuffer(buf) || !buf.length) return null;
141
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
142
+ && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a) return 'image/png';
143
+ // SOI (FF D8) plus the first marker: a bare FF D8 FF stub is not a JPEG.
144
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff && buf[3] >= 0xc0) return 'image/jpeg';
145
+ if (buf.length >= 6) {
146
+ const head6 = buf.toString('latin1', 0, 6);
147
+ if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif';
148
+ }
149
+ if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
150
+ // ISO-BMFF: `....ftyp<brand>`. avif/mp4 share the container; the brand decides.
151
+ if (buf.length >= 12 && buf.toString('latin1', 4, 8) === 'ftyp') {
152
+ const brand = buf.toString('latin1', 8, 12);
153
+ if (brand === 'avif' || brand === 'avis') return 'image/avif';
154
+ // Only the MP4 family is video/mp4. HEIC/HEIF (heic, mif1 …), QuickTime (qt ) and
155
+ // M4A share the container and are not types worca displays: refused, not mislabelled.
156
+ return MP4_BRANDS.has(brand) ? 'video/mp4' : null;
157
+ }
158
+ if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) return 'video/webm'; // EBML
159
+ if (buf.length >= 4 && buf.toString('latin1', 0, 4) === 'OggS') return 'audio/ogg';
160
+ if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WAVE') return 'audio/wav';
161
+ // MPEG audio: an ID3v2 tag, or a Layer III frame sync — the 11 sync bits, layer bits `01`,
162
+ // a legal bitrate index (not 1111) and sampling-rate index (not 11). The bare 11-bit sync
163
+ // alone matched a UTF-16LE BOM (FF FE) and any FF-FF run; Layer I/II are not types worca
164
+ // plays, so they fall through and are refused as unrecognized.
165
+ if (buf.length >= 3 && (buf.toString('latin1', 0, 3) === 'ID3'
166
+ || (buf[0] === 0xff && (buf[1] & 0xe6) === 0xe2 && (buf[2] & 0xf0) !== 0xf0 && (buf[2] & 0x0c) !== 0x0c))) return 'audio/mpeg';
167
+ if (buf.length >= 5) {
168
+ const at = buf.toString('latin1', 0, Math.min(buf.length, PDF_HEADER_WINDOW + 5)).indexOf('%PDF-');
169
+ if (at !== -1 && at <= PDF_HEADER_WINDOW) return 'application/pdf';
170
+ }
171
+ return sniffText(buf);
172
+ }
173
+
174
+ /** The body after the prelude an XML file may carry: an `<?xml … ?>` prolog, comments, one
175
+ * DOCTYPE (with or without an internal subset). A DOCTYPE names the document type outright,
176
+ * so only `svg` may pass it. Null when the prelude is unterminated or names another type. */
177
+ function afterXmlPrelude(head) {
178
+ let s = head;
179
+ if (s.startsWith('<?xml')) {
180
+ const end = s.indexOf('?>');
181
+ if (end === -1) return null;
182
+ s = s.slice(end + 2);
183
+ }
184
+ for (;;) {
185
+ s = s.trimStart();
186
+ if (s.startsWith('<!--')) {
187
+ const end = s.indexOf('-->', 4);
188
+ if (end === -1) return null;
189
+ s = s.slice(end + 3);
190
+ continue;
191
+ }
192
+ const doctype = /^<!doctype\s+([^\s>\[]+)[^>\[]*(?:\[[\s\S]*?\]\s*)?>/i.exec(s);
193
+ if (doctype) {
194
+ if (doctype[1].toLowerCase() !== 'svg') return null;
195
+ s = s.slice(doctype[0].length);
196
+ continue;
197
+ }
198
+ return s;
199
+ }
200
+ }
201
+
202
+ /** A body that opens with '<': its ROOT element decides. Only a root `svg` (prefixed or not)
203
+ * is an inert image; any other root element is markup and refused, whatever prolog it wears
204
+ * (an XHTML document that merely CONTAINS an svg is markup). A prelude followed by PROSE —
205
+ * a markdown file that opens with an HTML comment — is text, exactly like a body that never
206
+ * started with '<'; a prelude followed by nothing, or by a non-element such as `<?php`, is
207
+ * unrecognized. */
208
+ function sniffMarkup(head) {
209
+ const rest = afterXmlPrelude(head);
210
+ if (!rest) return null;
211
+ const root = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(rest);
212
+ if (root) return root[1].toLowerCase() === 'svg' ? 'image/svg+xml' : null;
213
+ return rest.startsWith('<') ? null : 'text/plain';
214
+ }
215
+
216
+ /** E9: text has no magic number, so it is probed by CONTENT. A body that is not
217
+ * clean UTF-8, or whose first non-space byte is '<' without being xml/svg, is
218
+ * refused — that is §7's "html, xhtml, anything scriptable or unrecognized". */
219
+ function sniffText(buf) {
220
+ let text;
221
+ try {
222
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
223
+ } catch {
224
+ return null;
225
+ }
226
+ // A C0 control other than tab / LF / CR is not text (and no regex here: a `\\u` escape
227
+ // in source is a trap for the tools that copy this file).
228
+ for (let i = 0; i < text.length; i++) {
229
+ const k = text.charCodeAt(i);
230
+ if (k < 32 && k !== 9 && k !== 10 && k !== 13) return null;
231
+ }
232
+ const head = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).trimStart(); // strip a BOM
233
+ if (!head) return null; // an empty or blank body is nothing worca can display
234
+ // Markup is decided by its ROOT element, read through the prelude an SVG file may open
235
+ // with (a prolog, comments, an svg DOCTYPE) — see sniffMarkup.
236
+ if (head.startsWith('<')) return sniffMarkup(head);
237
+ if (head.startsWith('{') || head.startsWith('[')) {
238
+ try { JSON.parse(text); return 'application/json'; } catch { /* not json after all */ }
239
+ }
240
+ if (/^diff --git /m.test(head) || (/^--- /m.test(head) && /^\+\+\+ /m.test(head))) return 'text/x-diff';
241
+ return 'text/plain';
242
+ }
243
+
244
+ /** Sniff a file on disk: the magic window first, and only a small body is read
245
+ * whole for the text probes; a larger body with no binary magic is TEXT_TOO_BIG. */
246
+ async function sniffFile(abs, size) {
247
+ const fh = await open(abs, 'r');
248
+ try {
249
+ const head = Buffer.alloc(Math.min(SNIFF_HEAD, Math.max(size, 1)));
250
+ const { bytesRead } = await fh.read(head, 0, head.length, 0);
251
+ const binary = sniffBinaryOnly(head.subarray(0, bytesRead));
252
+ if (binary) return binary;
253
+ } finally {
254
+ await fh.close();
255
+ }
256
+ if (size > TEXT_SNIFF_MAX) return TEXT_TOO_BIG;
257
+ return sniffText(await readFile(abs));
258
+ }
259
+
260
+ /** sniffMime minus the text fallback — used when the head alone is in hand. */
261
+ function sniffBinaryOnly(head) {
262
+ const mime = sniffMime(head);
263
+ return mime && ASK_FILE_MIMES[mime].trust !== 'text' && mime !== 'image/svg+xml' ? mime : null;
264
+ }
265
+
266
+ /** Is `candidate` (already realpath'd) inside `root` (already realpath'd)? Exported so the
267
+ * case-fold arm is testable directly: macOS realpath() preserves whatever case it was
268
+ * handed, so no on-disk fixture can prove the fold on one host. */
269
+ export function isInside(root, candidate, { fold = false } = {}) {
270
+ const a = fold ? root.toLowerCase() : root;
271
+ const b = fold ? candidate.toLowerCase() : candidate;
272
+ const prefix = a.endsWith(sep) ? a : a + sep;
273
+ return b === a || b.startsWith(prefix);
274
+ }
275
+
276
+ /** sha256 of a file, streamed (a 25 MB body must never be buffered to be hashed). */
277
+ function hashFile(abs) {
278
+ return new Promise((res, rej) => {
279
+ const h = createHash('sha256');
280
+ const s = createReadStream(abs);
281
+ s.on('error', rej);
282
+ s.on('data', (c) => h.update(c));
283
+ s.on('end', () => res(h.digest('hex')));
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Snapshot every file an ask references (spec §7). Resolves each ref against the
289
+ * roots IN ORDER (the node working tree, then the pipeline dir), refuses
290
+ * anything that fails the shape / containment / kind / cap rules, and copies the
291
+ * survivors to `<destDir>/<index><ext>` beside a `manifest.json` that is the ONE
292
+ * source both file routes read.
293
+ *
294
+ * ANY error means the whole ask is refused by gate 2, so the caller must check
295
+ * `errors` before using `files`.
296
+ *
297
+ * @param {{refs: Array<{path:string, rel:string, accept?:string[]}>, roots: string[],
298
+ * destDir: string, platform?: string}} args
299
+ * @returns {Promise<{files: Array<{index:number, rel:string, name:string, mime:string,
300
+ * bytes:number, sha256:string, stored:string}>, errors: Array<{path:string, code:string, message:string}>}>}
301
+ */
302
+ export async function snapshotAskFiles({ refs, roots, destDir, platform = process.platform }) {
303
+ const files = [];
304
+ const errors = [];
305
+ const list = Array.isArray(refs) ? refs : [];
306
+ if (list.length > ASK_LIMITS.filesPerAsk) {
307
+ errors.push({ path: 'data', code: 'too-many',
308
+ message: `an ask may reference at most ${ASK_LIMITS.filesPerAsk} files (got ${list.length})` });
309
+ return { files, errors };
310
+ }
311
+ const realRoots = [];
312
+ for (const r of roots || []) {
313
+ if (!r) continue;
314
+ try { realRoots.push(await realpath(r)); } catch { /* a root that does not exist contains nothing */ }
315
+ }
316
+ // macOS and Windows are case-insensitive by default; Linux is not. Injected so
317
+ // both arms are testable from one host.
318
+ const fold = platform === 'win32' || platform === 'darwin';
319
+ let total = 0;
320
+ for (let index = 0; index < list.length; index++) {
321
+ const ref = list[index] || {};
322
+ const at = typeof ref.path === 'string' && ref.path ? ref.path : 'data';
323
+ const rel = typeof ref.rel === 'string' ? ref.rel : '';
324
+ const fail = (code, message) => errors.push({ path: at, code, message });
325
+
326
+ const shape = refusePathShape(rel);
327
+ if (shape) { fail('file-path', shape); continue; }
328
+ // Either separator is accepted by the shape rule, so the segments are joined
329
+ // NATIVELY here: a `mockups\a.png` an agent wrote on Windows resolves on every host.
330
+ const segments = rel.split(/[\\/]+/).filter(Boolean);
331
+
332
+ let abs = null;
333
+ for (const root of realRoots) {
334
+ let candidate;
335
+ try { candidate = await realpath(resolve(root, ...segments)); } catch { continue; }
336
+ if (!isInside(root, candidate, { fold })) continue; // a symlink out of the tree refuses itself
337
+ abs = candidate;
338
+ break;
339
+ }
340
+ if (!abs) { fail('file-path', `"${rel}" is not inside the node working tree or the pipeline dir`); continue; }
341
+
342
+ const st = await lstat(abs).catch(() => null);
343
+ if (!st || !st.isFile()) { fail('file-path', `"${rel}" is not a regular file`); continue; }
344
+ if (st.size > ASK_LIMITS.fileBytes) {
345
+ fail('too-big', `"${rel}" is ${st.size} bytes; the limit is ${ASK_LIMITS.fileBytes}`);
346
+ continue;
347
+ }
348
+ if (total + st.size > ASK_LIMITS.askBytes) {
349
+ fail('too-big', `one ask may carry at most ${ASK_LIMITS.askBytes} bytes of files`);
350
+ continue;
351
+ }
352
+
353
+ const mime = await sniffFile(abs, st.size);
354
+ if (mime === TEXT_TOO_BIG) {
355
+ fail('too-big', `"${rel}" is ${st.size} bytes with no recognized file signature; a text preview may not exceed ${TEXT_SNIFF_MAX} bytes`);
356
+ continue;
357
+ }
358
+ if (!mime || !ASK_FILE_MIMES[mime]) {
359
+ fail('type', `"${rel}" is not a file type worca can display`);
360
+ continue;
361
+ }
362
+ if (!mimeMatchesAccept(mime, ref.accept)) {
363
+ fail('type', `"${rel}" is ${mime}; this field accepts ${(ref.accept || []).join(', ')}`);
364
+ continue;
365
+ }
366
+
367
+ const stored = `${index}${ASK_FILE_MIMES[mime].ext}`;
368
+ await mkdir(destDir, { recursive: true });
369
+ const target = join(destDir, stored);
370
+ await copyFile(abs, target);
371
+ files.push({
372
+ index, rel, name: segments[segments.length - 1] || rel, mime,
373
+ bytes: st.size, sha256: await hashFile(target), stored,
374
+ });
375
+ total += st.size;
376
+ }
377
+ if (files.length && !errors.length) {
378
+ await writeFile(join(destDir, 'manifest.json'), `${JSON.stringify({ version: 1, files }, null, 2)}\n`, 'utf8');
379
+ }
380
+ return { files, errors };
381
+ }
382
+
383
+ /**
384
+ * The file routes' reader: ONE manifest entry by its index. The returned
385
+ * `stored` is re-validated against STORED_RE, so even a hand-edited manifest can
386
+ * never name a path — it is the only string the route joins onto `dir`.
387
+ * @param {string} dir `<pipelineDir>/ask-files/<askId>`
388
+ * @param {number} index
389
+ * @returns {Promise<{index:number, stored:string, mime:string, name:string, bytes:number, sha256:string}|null>}
390
+ */
391
+ export async function readAskFileEntry(dir, index) {
392
+ let manifest;
393
+ try {
394
+ manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
395
+ } catch {
396
+ return null;
397
+ }
398
+ const hit = (Array.isArray(manifest?.files) ? manifest.files : []).find((f) => f && f.index === index);
399
+ if (!hit || typeof hit.stored !== 'string' || !STORED_RE.test(hit.stored)) return null;
400
+ if (!hit.mime || !ASK_FILE_MIMES[hit.mime]) return null;
401
+ return {
402
+ index, stored: hit.stored, mime: hit.mime,
403
+ name: typeof hit.name === 'string' ? hit.name : hit.stored,
404
+ bytes: Number(hit.bytes) || 0, sha256: typeof hit.sha256 === 'string' ? hit.sha256 : '',
405
+ };
406
+ }