@rubytech/create-sitedesk-code 0.1.524 → 0.1.526

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 (126) hide show
  1. package/dist/__tests__/samba-provision.test.js +46 -1
  2. package/dist/__tests__/websockify-bind-wiring.test.js +47 -0
  3. package/dist/index.js +39 -1
  4. package/dist/samba-provision.js +37 -0
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -7
  7. package/payload/platform/plugins/admin/skills/superpowers-sprint/SKILL.md +26 -0
  8. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +12 -0
  9. package/payload/platform/plugins/memory/PLUGIN.md +10 -0
  10. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js +5 -1
  11. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js.map +1 -1
  12. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.d.ts +2 -0
  13. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.d.ts.map +1 -0
  14. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.js +141 -0
  15. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.js.map +1 -0
  16. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts +1 -1
  17. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts.map +1 -1
  18. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +6 -0
  19. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -1
  20. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.d.ts +45 -0
  21. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.d.ts.map +1 -0
  22. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.js +122 -0
  23. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.js.map +1 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js +26 -0
  25. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js.map +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.d.ts +2 -0
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.d.ts.map +1 -0
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.js +201 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.js.map +1 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  31. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +89 -33
  32. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  33. package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
  34. package/payload/platform/plugins/scheduling/PLUGIN.md +6 -0
  35. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.d.ts +2 -0
  36. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.js +148 -0
  38. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.js.map +1 -0
  39. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +83 -6
  40. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  41. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +90 -5
  42. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  43. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js +173 -2
  44. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js.map +1 -1
  45. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +73 -0
  46. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  47. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +194 -0
  48. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  49. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +25 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  51. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +81 -8
  52. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  53. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-event-agent.test.js +39 -0
  54. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-event-agent.test.js.map +1 -1
  55. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +9 -1
  57. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  58. package/payload/platform/scripts/__tests__/check-canonical-tool-names.test.sh +162 -0
  59. package/payload/platform/scripts/__tests__/check-no-task-id-leaks.test.sh +53 -0
  60. package/payload/platform/scripts/__tests__/task-id-citation.test.sh +72 -0
  61. package/payload/platform/scripts/check-canonical-tool-names.mjs +36 -4
  62. package/payload/platform/scripts/check-no-task-id-leaks.mjs +80 -62
  63. package/payload/platform/scripts/lib/canonical-tool-names.mjs +130 -26
  64. package/payload/platform/scripts/lib/task-id-citation.mjs +67 -0
  65. package/payload/platform/scripts/vnc.sh +45 -1
  66. package/payload/server/{chunk-VUXKXOJU.js → chunk-7MGR5M6G.js} +4 -2
  67. package/payload/server/{manager-4TJK3YOY.js → manager-6XOWHREK.js} +1 -1
  68. package/payload/server/server.js +10 -4
  69. package/payload/platform/.docs/search-surface-contract.md +0 -58
  70. package/payload/platform/docs/superpowers/plans/2026-06-02-task-610-follower-202-retry.md +0 -372
  71. package/payload/platform/docs/superpowers/plans/2026-06-04-public-agent-knowledge-delivery.md +0 -230
  72. package/payload/platform/docs/superpowers/plans/2026-06-23-account-filesystem-schema.md +0 -544
  73. package/payload/platform/docs/superpowers/plans/2026-07-11-task-1557-operator-on-behalf-preference-attribution.md +0 -542
  74. package/payload/platform/docs/superpowers/plans/2026-07-13-account-schema-ontology-projection.md +0 -547
  75. package/payload/platform/docs/superpowers/plans/2026-07-14-graph-top-level-labels-ontology-single-source.md +0 -458
  76. package/payload/platform/docs/superpowers/plans/2026-07-17-task-1736-retire-mcp-stderr-tee.md +0 -397
  77. package/payload/platform/docs/superpowers/plans/2026-07-18-graph-native-ledger.md +0 -807
  78. package/payload/platform/docs/superpowers/plans/2026-07-18-ledger-write-path-fix.md +0 -109
  79. package/payload/platform/docs/superpowers/plans/2026-07-20-storage-pages-create.md +0 -82
  80. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +0 -673
  81. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +0 -1244
  82. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +0 -462
  83. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +0 -321
  84. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1829-platform-ui-typecheck.md +0 -830
  85. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +0 -2177
  86. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +0 -124
  87. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +0 -107
  88. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +0 -205
  89. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +0 -748
  90. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +0 -1716
  91. package/payload/platform/docs/superpowers/plans/2026-07-23-task-1930-adherence-enforcement.md +0 -422
  92. package/payload/platform/docs/superpowers/plans/2026-07-24-task-1942-subaccount-switcher-brand-head.md +0 -327
  93. package/payload/platform/docs/superpowers/plans/2026-07-25-task-1974-uploads-intake-inbox.md +0 -235
  94. package/payload/platform/docs/superpowers/plans/2026-07-25-task-1976-intra-folder-hygiene.md +0 -215
  95. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1902-account-owned-entry-declarations.md +0 -555
  96. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +0 -482
  97. package/payload/platform/docs/superpowers/plans/2026-07-26-task-2023-sidebar-sessions-async-reads.md +0 -394
  98. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2016-disabled-agent-routing.md +0 -624
  99. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2028-declared-file-write-deny.md +0 -303
  100. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2052-account-machinery-write-fence.md +0 -250
  101. package/payload/platform/docs/superpowers/plans/2026-07-28-task-2097-email-signature-set.md +0 -65
  102. package/payload/platform/docs/superpowers/specs/2026-06-02-task-610-follower-202-retry-design.md +0 -116
  103. package/payload/platform/docs/superpowers/specs/2026-06-23-account-filesystem-schema-design.md +0 -170
  104. package/payload/platform/docs/superpowers/specs/2026-07-11-task-1557-operator-on-behalf-preference-attribution-design.md +0 -90
  105. package/payload/platform/docs/superpowers/specs/2026-07-13-account-schema-ontology-projection-design.md +0 -178
  106. package/payload/platform/docs/superpowers/specs/2026-07-14-graph-top-level-labels-ontology-single-source-design.md +0 -119
  107. package/payload/platform/docs/superpowers/specs/2026-07-17-task-1736-retire-mcp-stderr-tee-design.md +0 -128
  108. package/payload/platform/docs/superpowers/specs/2026-07-18-graph-native-ledger-design.md +0 -241
  109. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +0 -177
  110. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +0 -201
  111. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +0 -217
  112. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +0 -80
  113. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +0 -97
  114. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1829-platform-ui-typecheck-design.md +0 -163
  115. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +0 -163
  116. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +0 -121
  117. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +0 -103
  118. package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md +0 -81
  119. package/payload/platform/docs/superpowers/specs/2026-07-24-task-1942-subaccount-switcher-brand-head-design.md +0 -35
  120. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1902-account-owned-entry-declarations-design.md +0 -232
  121. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +0 -200
  122. package/payload/platform/docs/superpowers/specs/2026-07-26-task-2014-ui-suite-nondeterminism-design.md +0 -169
  123. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2016-disabled-agent-routing-design.md +0 -139
  124. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +0 -127
  125. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2052-account-machinery-write-fence-design.md +0 -150
  126. package/payload/platform/docs/superpowers/specs/2026-07-28-task-2097-email-signature-set-design.md +0 -155
@@ -24,9 +24,10 @@
24
24
  // rule covers test titles, and test bodies that assert on citations are
25
25
  // equivalent to titles for this gate's purpose.
26
26
 
27
- import { readFileSync, readdirSync, statSync } from 'node:fs'
27
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
28
28
  import { dirname, join, relative, sep, basename } from 'node:path'
29
29
  import { fileURLToPath } from 'node:url'
30
+ import { findCitations } from './lib/task-id-citation.mjs'
30
31
 
31
32
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
32
33
  // REPO_ROOT is overridable so the `.test.sh` self-test can point the gate at a
@@ -40,7 +41,6 @@ const SCAN_ROOTS = [
40
41
  join(REPO_ROOT, 'platform', 'plugins'),
41
42
  join(REPO_ROOT, 'platform', 'ui', 'server'),
42
43
  join(REPO_ROOT, 'platform', 'ui', 'app'),
43
- join(REPO_ROOT, 'packages', 'create-maxy', 'src'),
44
44
  ]
45
45
 
46
46
  const ALLOWED_EXTS = new Set(['.sh', '.ts', '.tsx', '.js', '.mjs', '.cjs'])
@@ -52,27 +52,70 @@ const TASK_ID_RE = /\(Task \d+\)/
52
52
  // Markdown mode. Unlike code files — where a citation is legal in a comment and
53
53
  // only the parenthetical operator-visible form is rejected — a shipped `.md`
54
54
  // loads into a client-facing agent's context wholesale, so ANY citation form
55
- // leaks. Catches `Task NNN`, `(Task NNN)`, `Tasks NNN`, `since Task NNN`. It
56
- // deliberately does NOT match bare `#NNN`: every `#NNN` in the corpus is a hex
57
- // colour and there is no signal to separate `#732`-issue from `#732`-hex.
58
- // Two alternatives:
59
- // `\bTasks?[\s-]+\d{2,4}` capital-T, any separator: `Task 732`, `(Task 732)`,
60
- // `Tasks 12`, the hyphenated `Pre-Task-748` / `post-Task-904` forms.
61
- // `\btasks?\s+\d{2,4}` lowercase but SPACE-separated only: catches prose
62
- // citations like "before task 127", while a lowercase HYPHENATED `task-537`
63
- // stays exempt because that shape is a branch slug or a `.tasks/<n>-…-task-NNN.md`
64
- // file-path reference (file paths are out of this gate's scope). The 2-digit
65
- // floor keeps single-digit domain counts ("task 1 of 3") out.
66
- const MD_TASK_ID_RE = /\bTasks?[\s-]+\d{2,4}|\btasks?\s+\d{2,4}/
55
+ // leaks. The regex and the paragraph-aware scan live in
56
+ // `lib/task-id-citation.mjs`, shared with the bundle's payload audit so the two
57
+ // surfaces cannot disagree about what a citation is.
58
+ //
59
+ // The roots are what the client payload CARRIES, not a list somebody maintains.
60
+ // `bundle.js` copies all of `platform/` and all of `premium-plugins/` into the
61
+ // payload, so those two trees are the roots. Naming `platform/plugins` and
62
+ // `platform/templates` instead is what let `platform/docs` ship 296 citations
63
+ // to every install without this gate ever looking at it.
67
64
  const MD_SCAN_ROOTS = [
68
- join(REPO_ROOT, 'platform', 'plugins'),
69
- join(REPO_ROOT, 'platform', 'templates'),
70
- // Premium bundles are copied verbatim into the client payload
71
- // (`payload/premium-plugins/<bundle>/`), so their shipped markdown loads into
72
- // the client agent the same as core plugins — any citation form leaks.
65
+ join(REPO_ROOT, 'platform'),
73
66
  join(REPO_ROOT, 'premium-plugins'),
74
67
  ]
75
68
 
69
+ // The trees under the roots above that `bundle.js` keeps OUT of the payload,
70
+ // mirroring its filter. A path here is unscanned because it does not ship; a
71
+ // path not here is scanned because it does. Each entry names the exclusion in
72
+ // `bundle.js` that makes it true, so the two files can be checked against each
73
+ // other by reading them. `.docs/` and `.tasks/` need no entry: they sit outside
74
+ // both roots and stay citation-rich.
75
+ //
76
+ // This list is a hand-kept mirror, and that is the one place the two surfaces
77
+ // can still drift. An exclusion added to `bundle.js` and forgotten here makes
78
+ // the gate scan a tree that does not ship, which is noisy but fail-safe. An
79
+ // exclusion REMOVED from `bundle.js` and forgotten here is the dangerous
80
+ // direction: a tree starts shipping and stops being scanned. The payload audit
81
+ // in `bundle.js` is what catches that one, because it consults neither list.
82
+ const MD_NOT_SHIPPED = [
83
+ // bundle.js `src.startsWith(UI_DIR)` — ui ships as a compiled bundle, not source.
84
+ join(REPO_ROOT, 'platform', 'ui'),
85
+ // bundle.js `src.startsWith(DOCS_DIR)` — sprint plans and specs are
86
+ // development records and do not ship.
87
+ join(REPO_ROOT, 'platform', 'docs'),
88
+ // bundle.js `src.startsWith(DOT_DOCS_DIR)` — same, for the dotted twin. The
89
+ // widened scan is what found this one: a `platform/*/` census does not match
90
+ // a dotdir, so `platform/.docs` shipped unseen by both the old roots and the
91
+ // measurement that motivated the change.
92
+ join(REPO_ROOT, 'platform', '.docs'),
93
+ // bundle.js `src.includes("/config/accounts/")`, `"/data/accounts/"` and
94
+ // `"/data/uploads/"` — live account data never ships. An account directory
95
+ // holds real markdown (`SCHEMA.md`, `AGENTS.md`), so without these three the
96
+ // gate would read a client's own files and could refuse a commit over a
97
+ // citation in something no payload carries.
98
+ join(REPO_ROOT, 'platform', 'config', 'accounts'),
99
+ join(REPO_ROOT, 'platform', 'data', 'accounts'),
100
+ join(REPO_ROOT, 'platform', 'data', 'uploads'),
101
+ ]
102
+
103
+ function isNotShipped(file) {
104
+ return MD_NOT_SHIPPED.some((dir) => file === dir || file.startsWith(dir + sep))
105
+ }
106
+
107
+ // Every root names itself on every run. Before this, a tree outside the roots
108
+ // produced no output at all, so nothing distinguished "scanned and clean" from
109
+ // "never looked at", and a root that stopped existing was swallowed by walk()'s
110
+ // bare catch — which is exactly how the archived `packages/create-maxy`
111
+ // installer tree sat in this list for months pointing at a directory that does
112
+ // not exist, proving nothing. A missing root logs rather than fails, because
113
+ // the `.test.sh` fixture tree deliberately creates only some of them.
114
+ function logRoot(kind, root, files) {
115
+ const exists = existsSync(root) ? 'yes' : 'no'
116
+ console.log(`[task-id-leak] root=${relative(REPO_ROOT, root)} kind=${kind} exists=${exists} files=${files}`)
117
+ }
118
+
76
119
  function isCommentLine(line) {
77
120
  const trimmed = line.replace(/^\s+/, '')
78
121
  if (trimmed.startsWith('#')) return true
@@ -115,6 +158,7 @@ function* walk(root) {
115
158
  const leaks = []
116
159
 
117
160
  for (const root of SCAN_ROOTS) {
161
+ let scanned = 0
118
162
  for (const file of walk(root)) {
119
163
  if (!shouldScanFile(file)) continue
120
164
  let content
@@ -123,6 +167,7 @@ for (const root of SCAN_ROOTS) {
123
167
  } catch {
124
168
  continue
125
169
  }
170
+ scanned++
126
171
  const lines = content.split('\n')
127
172
  for (let i = 0; i < lines.length; i++) {
128
173
  const line = lines[i]
@@ -131,59 +176,30 @@ for (const root of SCAN_ROOTS) {
131
176
  leaks.push({ file: relative(REPO_ROOT, file), line: i + 1, content: line.trim() })
132
177
  }
133
178
  }
179
+ logRoot('code', root, scanned)
134
180
  }
135
181
 
136
- // Markdown scan — shipped operator-loadable docs. Reuses `walk` (which already
137
- // skips node_modules/dist/build/.next/payload/__tests__) and matches against
138
- // MD_TASK_ID_RE with no comment skip: the whole doc is agent content.
139
- // .docs/ and .tasks/ are not under MD_SCAN_ROOTS, so they stay citation-rich.
140
- //
141
- // Scanned per paragraph, not per line: a markdown soft-wrap can split a single
142
- // citation across two source lines (`... (Task\n659) ...`), which renders as
143
- // `(Task 659)` but matches neither line in isolation. Consecutive non-blank
144
- // lines are joined with a space (the soft-break a renderer would insert) and
145
- // scanned as one string; a blank line ends the paragraph, so the join never
146
- // crosses a paragraph break. Every match in the paragraph is reported, mapped
147
- // back to the source line where the match starts, preserving exact-line and
148
- // multi-citation reporting.
149
- const MD_TASK_ID_RE_G = new RegExp(MD_TASK_ID_RE.source, 'g')
150
-
151
- function flushParagraph(file, joined, lineStarts) {
152
- if (joined === '') return
153
- for (const m of joined.matchAll(MD_TASK_ID_RE_G)) {
154
- let lineNo = lineStarts[0].lineNo
155
- for (const ls of lineStarts) {
156
- if (ls.offset <= m.index) lineNo = ls.lineNo
157
- }
158
- leaks.push({ file: relative(REPO_ROOT, file), line: lineNo, content: m[0] })
159
- }
160
- }
161
-
182
+ // Markdown scan — every `.md` the payload carries. Reuses `walk` (which already
183
+ // skips node_modules/dist/build/.next/payload/__tests__), drops the trees
184
+ // `bundle.js` excludes from the payload, and applies no comment skip: the whole
185
+ // doc is agent content.
162
186
  for (const root of MD_SCAN_ROOTS) {
187
+ let scanned = 0
163
188
  for (const file of walk(root)) {
164
189
  if (!file.endsWith('.md')) continue
190
+ if (isNotShipped(file)) continue
165
191
  let content
166
192
  try {
167
193
  content = readFileSync(file, 'utf-8')
168
194
  } catch {
169
195
  continue
170
196
  }
171
- const lines = content.split('\n')
172
- let joined = ''
173
- let lineStarts = []
174
- for (let i = 0; i < lines.length; i++) {
175
- if (lines[i].trim() === '') {
176
- flushParagraph(file, joined, lineStarts)
177
- joined = ''
178
- lineStarts = []
179
- continue
180
- }
181
- if (joined !== '') joined += ' '
182
- lineStarts.push({ offset: joined.length, lineNo: i + 1 })
183
- joined += lines[i]
197
+ scanned++
198
+ for (const c of findCitations(content)) {
199
+ leaks.push({ file: relative(REPO_ROOT, file), line: c.line, content: c.match })
184
200
  }
185
- flushParagraph(file, joined, lineStarts)
186
201
  }
202
+ logRoot('md', root, scanned)
187
203
  }
188
204
 
189
205
  if (leaks.length > 0) {
@@ -194,8 +210,10 @@ if (leaks.length > 0) {
194
210
  console.error('')
195
211
  console.error('Code files: citations belong in inline comments only, never in operator-visible')
196
212
  console.error('strings (stderr/stdout, logs, systemd --description=, error messages, console.*).')
197
- console.error('Shipped markdown (platform/plugins, platform/templates, premium-plugins): no task')
198
- console.error('citation in any form — the whole doc loads into a client-facing agent. Dev docs')
199
- console.error('(.docs/, .tasks/) are exempt and keep their citations.')
213
+ console.error('Shipped markdown (everything under platform/ and premium-plugins/ that the payload')
214
+ console.error('carries): no task citation in any form — the whole doc loads into a client-facing')
215
+ console.error('agent. The trees that do NOT ship are exempt, and MD_NOT_SHIPPED in this file is')
216
+ console.error('the list of them; the dev docs (.docs/, .tasks/) sit outside the roots entirely')
217
+ console.error('and keep their citations.')
200
218
  process.exit(1)
201
219
  }
@@ -36,44 +36,61 @@ function extractFrontmatter(md) {
36
36
  return md.slice(3, end)
37
37
  }
38
38
 
39
- // Collect `- name: <x>` entries under a bare `tools:` block and under a bare
40
- // `hidden:` block. Both contribute names a specialist may list in its `tools:`
39
+ // Collect the declared entries under a bare `tools:` block and a bare `hidden:`
40
+ // block. Both contribute names a specialist may list in its `tools:`
41
41
  // frontmatter, so both belong to the live set. Task 1998 gave the hidden block
42
- // the same name+riskClass shape as tools, so the two branches now read alike;
43
- // this parser stays separate from tool-surface.ts on purpose, so a drift in one
42
+ // the same name+riskClass shape as tools, so the two branches read alike; this
43
+ // parser stays separate from tool-surface.ts on purpose, so a drift in one
44
44
  // shows up as a diff rather than as agreement between a parser and itself.
45
- function parseToolNames(frontmatter) {
45
+ //
46
+ // Field values are returned as raw trimmed strings, or absent when the manifest
47
+ // has no such line. Judging them is the caller's job: the generator wants only
48
+ // names, and the gate wants to decide whether a value is one the session
49
+ // manager would accept.
50
+ export function parseToolEntries(frontmatter) {
46
51
  const lines = frontmatter.split('\n')
47
- const names = []
52
+ const entries = []
48
53
  let mode = null // 'tools' | 'hidden' | null
54
+ let current = null
55
+ const flush = () => { if (current) { entries.push(current); current = null } }
49
56
  for (const line of lines) {
50
57
  if (/^tools\s*:/.test(line)) {
58
+ flush()
51
59
  mode = /^tools\s*:\s*$/.test(line) ? 'tools' : null
52
60
  continue
53
61
  }
54
62
  if (/^hidden\s*:/.test(line)) {
63
+ flush()
55
64
  mode = /^hidden\s*:\s*$/.test(line) ? 'hidden' : null
56
65
  continue
57
66
  }
58
- if (/^\S/.test(line) && line.trim() !== '') { mode = null; continue }
59
- if (mode === 'tools') {
60
- const m = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
61
- if (m) names.push(m[1])
62
- } else if (mode === 'hidden') {
63
- const m = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
64
- if (m) names.push(m[1])
65
- }
67
+ if (/^\S/.test(line) && line.trim() !== '') { flush(); mode = null; continue }
68
+ if (mode === null) continue
69
+ const name = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
70
+ if (name) { flush(); current = { block: mode, name: name[1] }; continue }
71
+ // A list item that does not lead with `name:` is malformed — a tools entry
72
+ // with no name, or a hidden entry written as a bare string. loadToolSurface
73
+ // throws on both, so the entry is recorded without a name and reported by
74
+ // validateToolEntries rather than silently dropped.
75
+ if (/^\s+-\s+/.test(line)) { flush(); current = { block: mode }; continue }
76
+ if (current === null) continue
77
+ const pub = line.match(/^\s+publicAllowlist:\s*(.+?)\s*$/)
78
+ if (pub) { current.publicAllowlist = pub[1].trim(); continue }
79
+ const adm = line.match(/^\s+adminAllowlist:\s*(.+?)\s*$/)
80
+ if (adm) { current.adminAllowlist = adm[1].trim(); continue }
81
+ const risk = line.match(/^\s+riskClass:\s*(.+?)\s*$/)
82
+ if (risk) { current.riskClass = risk[1].trim(); continue }
66
83
  }
67
- return names
84
+ flush()
85
+ return entries
68
86
  }
69
87
 
70
- /** Returns { plugins, names } `plugins` is the sorted list of maxy plugin
71
- * directory basenames that declare a tool surface; `names` is the sorted list
72
- * of canonical long-prefix tool names across all of them. */
73
- export function computeCanonicalToolNames(pluginsRoot = PLATFORM_PLUGINS_ROOT) {
74
- const plugins = []
75
- const names = new Set()
76
- if (!existsSync(pluginsRoot)) return { plugins, names: [] }
88
+ /** Walk every plugin directory that has a PLUGIN.md with a parseable
89
+ * frontmatter and at least one declared entry. Shared by the name derivation
90
+ * and the field validation so the two can never disagree about which
91
+ * manifests are in scope. */
92
+ function* walkPluginManifests(pluginsRoot) {
93
+ if (!existsSync(pluginsRoot)) return
77
94
  for (const entry of readdirSync(pluginsRoot).sort()) {
78
95
  if (entry.startsWith('.')) continue
79
96
  const dir = join(pluginsRoot, entry)
@@ -82,10 +99,97 @@ export function computeCanonicalToolNames(pluginsRoot = PLATFORM_PLUGINS_ROOT) {
82
99
  if (!existsSync(manifest)) continue
83
100
  const fm = extractFrontmatter(readFileSync(manifest, 'utf-8'))
84
101
  if (fm === null) continue
85
- const toolNames = parseToolNames(fm)
86
- if (toolNames.length === 0) continue
87
- plugins.push(entry)
88
- for (const t of toolNames) names.add(canonicalToolName(entry, t))
102
+ const entries = parseToolEntries(fm)
103
+ if (entries.length === 0) continue
104
+ yield { plugin: entry, entries }
105
+ }
106
+ }
107
+
108
+ /** Returns { plugins, names } — `plugins` is the sorted list of maxy plugin
109
+ * directory basenames that declare a tool surface; `names` is the sorted list
110
+ * of canonical long-prefix tool names across all of them. */
111
+ export function computeCanonicalToolNames(pluginsRoot = PLATFORM_PLUGINS_ROOT) {
112
+ const plugins = []
113
+ const names = new Set()
114
+ for (const { plugin, entries } of walkPluginManifests(pluginsRoot)) {
115
+ plugins.push(plugin)
116
+ // A malformed entry has no name to derive a canonical name from. The gate
117
+ // reports it; this list stays exactly what it was before that shape was
118
+ // recorded, so the generated artifact does not move.
119
+ for (const e of entries) {
120
+ if (e.name !== undefined) names.add(canonicalToolName(plugin, e.name))
121
+ }
89
122
  }
90
123
  return { plugins: plugins.sort(), names: Array.from(names).sort() }
91
124
  }
125
+
126
+ /** Every declared entry across every plugin, stamped with its plugin. The
127
+ * build gate validates these against the session manager's RISK_CLASSES; see
128
+ * validateToolEntries. */
129
+ export function computeToolEntries(pluginsRoot = PLATFORM_PLUGINS_ROOT) {
130
+ const out = []
131
+ for (const { plugin, entries } of walkPluginManifests(pluginsRoot)) {
132
+ for (const e of entries) out.push({ plugin, ...e })
133
+ }
134
+ return out
135
+ }
136
+
137
+ // The gate validates risk classes against the session manager's own list rather
138
+ // than a copy, so the two cannot drift. The gate is plain .mjs run by bare node
139
+ // and the declaration is TypeScript, so it is read as text.
140
+ const RISK_CLASSES_RE = /export const RISK_CLASSES:[^=]*=\s*\[([^\]]*)\]/
141
+
142
+ /** The valid risk classes, read from tool-surface.ts. Throws rather than
143
+ * returning a fallback: a gate that validated against a hardcoded copy could
144
+ * drift from the parser it protects, and one that validated against an empty
145
+ * set would accept every value. */
146
+ export function readRiskClasses(tsPath) {
147
+ let src
148
+ try {
149
+ src = readFileSync(tsPath, 'utf-8')
150
+ } catch {
151
+ throw new Error(`cannot read ${tsPath}, which declares RISK_CLASSES — refusing to validate risk classes without it`)
152
+ }
153
+ const m = src.match(RISK_CLASSES_RE)
154
+ if (m === null) {
155
+ throw new Error(`no 'export const RISK_CLASSES ... = [...]' declaration in ${tsPath} — refusing to validate risk classes against a set that cannot be read`)
156
+ }
157
+ const classes = m[1]
158
+ .split(',')
159
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
160
+ .filter((s) => s.length > 0)
161
+ if (classes.length === 0) {
162
+ throw new Error(`RISK_CLASSES in ${tsPath} parsed to an empty set — refusing to validate against it`)
163
+ }
164
+ return classes
165
+ }
166
+
167
+ /** Judge parsed entries against the rules loadToolSurface enforces. A tools
168
+ * entry must declare both allowlist flags as YAML booleans and a riskClass in
169
+ * the set; a hidden entry must declare a riskClass and carries no allowlist
170
+ * flags at all. Missing and invalid are both violations, because
171
+ * loadToolSurface throws on both. Returns every violation rather than the
172
+ * first: an author who mistyped one field has often mistyped its sibling. */
173
+ export function validateToolEntries(entries, riskClasses) {
174
+ const valid = new Set(riskClasses)
175
+ const list = riskClasses.join('|')
176
+ const violations = []
177
+ for (const e of entries) {
178
+ if (typeof e.name !== 'string' || e.name.length === 0) {
179
+ violations.push({ plugin: e.plugin, name: '<unnamed>', message: 'tool entry has no name' })
180
+ continue
181
+ }
182
+ const add = (message) => violations.push({ plugin: e.plugin, name: e.name, message })
183
+ const subject = e.block === 'hidden' ? 'every hidden tool' : 'every tool'
184
+ if (e.block === 'tools') {
185
+ for (const field of ['publicAllowlist', 'adminAllowlist']) {
186
+ const v = e[field]
187
+ if (v === undefined) add(`${subject} must declare ${field}: <true|false>`)
188
+ else if (v !== 'true' && v !== 'false') add(`${field} must be true or false, got ${JSON.stringify(v)}`)
189
+ }
190
+ }
191
+ if (e.riskClass === undefined) add(`${subject} must declare riskClass: <${list}>`)
192
+ else if (!valid.has(e.riskClass)) add(`riskClass must be one of ${list}, got ${JSON.stringify(e.riskClass)}`)
193
+ }
194
+ return violations
195
+ }
@@ -0,0 +1,67 @@
1
+ // Single source of truth for what counts as an internal task citation in
2
+ // shipped markdown, and for how a markdown file is scanned for one.
3
+ //
4
+ // Two consumers, deliberately. `platform/scripts/check-no-task-id-leaks.mjs`
5
+ // scans the SOURCE trees at commit time; `packages/create-maxy-code/scripts/
6
+ // bundle.js` scans the ASSEMBLED payload at bundle time. They answer different
7
+ // questions, "could this ship?" and "did this ship?", and a divergence between
8
+ // two copies of the regex would let one pass what the other rejects, with the
9
+ // payload audit failing a publish for something the commit gate allowed. The
10
+ // regex and the scan live here so neither consumer carries its own copy.
11
+ //
12
+ // A shipped `.md` loads into a client-facing agent's context wholesale, so ANY
13
+ // citation form leaks. Catches `Task NNN`, `(Task NNN)`, `Tasks NNN`,
14
+ // `since Task NNN`. It deliberately does NOT match bare `#NNN`: every `#NNN` in
15
+ // the corpus is a hex colour and there is no signal to separate `#732`-issue
16
+ // from `#732`-hex.
17
+ // Two alternatives:
18
+ // `\bTasks?[\s-]+\d{2,4}` — capital-T, any separator: `Task 732`, `(Task 732)`,
19
+ // `Tasks 12`, the hyphenated `Pre-Task-748` / `post-Task-904` forms.
20
+ // `\btasks?\s+\d{2,4}` — lowercase but SPACE-separated only: catches prose
21
+ // citations like "before task 127", while a lowercase HYPHENATED `task-537`
22
+ // stays exempt because that shape is a branch slug or a
23
+ // `.tasks/<n>-…-task-NNN.md` file-path reference (file paths are out of
24
+ // scope). The 2-digit floor keeps single-digit domain counts ("task 1 of
25
+ // 3") out.
26
+ export const MD_TASK_ID_RE = /\bTasks?[\s-]+\d{2,4}|\btasks?\s+\d{2,4}/
27
+
28
+ // Scanned per paragraph, not per line: a markdown soft-wrap can split a single
29
+ // citation across two source lines (`... (Task\n659) ...`), which renders as
30
+ // `(Task 659)` but matches neither line in isolation. Consecutive non-blank
31
+ // lines are joined with a space (the soft-break a renderer would insert) and
32
+ // scanned as one string; a blank line ends the paragraph, so the join never
33
+ // crosses a paragraph break. Every match in the paragraph is reported, mapped
34
+ // back to the source line where the match starts, preserving exact-line and
35
+ // multi-citation reporting.
36
+ export function findCitations(content) {
37
+ const re = new RegExp(MD_TASK_ID_RE.source, 'g')
38
+ const found = []
39
+ const lines = content.split('\n')
40
+ let joined = ''
41
+ let lineStarts = []
42
+
43
+ function flush() {
44
+ if (joined === '') return
45
+ for (const m of joined.matchAll(re)) {
46
+ let lineNo = lineStarts[0].lineNo
47
+ for (const ls of lineStarts) {
48
+ if (ls.offset <= m.index) lineNo = ls.lineNo
49
+ }
50
+ found.push({ line: lineNo, match: m[0] })
51
+ }
52
+ }
53
+
54
+ for (let i = 0; i < lines.length; i++) {
55
+ if (lines[i].trim() === '') {
56
+ flush()
57
+ joined = ''
58
+ lineStarts = []
59
+ continue
60
+ }
61
+ if (joined !== '') joined += ' '
62
+ lineStarts.push({ offset: joined.length, lineNo: i + 1 })
63
+ joined += lines[i]
64
+ }
65
+ flush()
66
+ return found
67
+ }
@@ -126,6 +126,45 @@ case ":$(echo "$CHROMIUM_REALPATH" | tr '/' ':'):" in
126
126
  ;;
127
127
  esac
128
128
  CHROMIUM_CONFINEMENT="non-snap"
129
+
130
+ # resolve the websockify bind address from the install-time config file
131
+ # (packages/create-maxy-code/src/index.ts writeWebsockifyBindFile). The
132
+ # installer decides it once, from the same classifier that decided the edge
133
+ # bind, so this script never re-derives host state and can never disagree with
134
+ # the edge about whether the box is publicly reachable.
135
+ #
136
+ # Called from the `start` branch rather than at the top level, unlike the
137
+ # Chromium resolution above: `stop`, `status` and the per-cycle `cdp-liveness`
138
+ # probe bind nothing, so a partial install should not take them down too.
139
+ #
140
+ # Absent or unparseable is fatal, matching brand.json and chromium-binary.path.
141
+ # This script and the file ship in the same payload, so absence means a
142
+ # tampered or partial install — and both possible defaults are wrong for one
143
+ # host class: a wildcard re-opens a cloud box to the internet, a loopback kills
144
+ # the LAN noVNC page on a Pi with no signal beyond a page that will not load.
145
+ resolve_websockify_bind() {
146
+ local websockify_bind_file websockify_bind_line
147
+ websockify_bind_file="${PLATFORM_ROOT}/config/websockify-bind"
148
+ if [ ! -r "$websockify_bind_file" ]; then
149
+ log "[vnc.sh:websockify] FATAL: ${websockify_bind_file} missing or unreadable"
150
+ echo "ERROR: ${websockify_bind_file} missing or unreadable." >&2
151
+ echo " Re-run the installer to provision the websockify bind posture." >&2
152
+ exit 1
153
+ fi
154
+ websockify_bind_line="$(head -n1 "$websockify_bind_file")"
155
+ WEBSOCKIFY_BIND="$(printf '%s\n' "$websockify_bind_line" | sed -n 's/.*bind=\([^ ]*\).*/\1/p')"
156
+ WEBSOCKIFY_BIND_KIND="$(printf '%s\n' "$websockify_bind_line" | sed -n 's/.*kind=\([^ ]*\).*/\1/p')"
157
+ case "$WEBSOCKIFY_BIND" in
158
+ "[::]"|"127.0.0.1") ;;
159
+ *)
160
+ log "[vnc.sh:websockify] FATAL: ${websockify_bind_file} names no recognised bind address (line=${websockify_bind_line})"
161
+ echo "ERROR: ${websockify_bind_file} does not name a recognised bind address." >&2
162
+ echo " Re-run the installer to rewrite it." >&2
163
+ exit 1
164
+ ;;
165
+ esac
166
+ [ -n "$WEBSOCKIFY_BIND_KIND" ] || WEBSOCKIFY_BIND_KIND="unknown"
167
+ }
129
168
  # The standing cdp-liveness probe runs every rss-sampler cycle; skip this
130
169
  # per-invocation resolution line on that path so vnc-boot.log is not spammed.
131
170
  # The FATAL guards above still run (and hard-exit) on every invocation.
@@ -541,7 +580,12 @@ case "${1:-}" in
541
580
  pkill -f "websockify.*${WEBSOCKIFY_PORT}" 2>/dev/null || true
542
581
  sleep 0.2
543
582
 
544
- websockify --web /usr/share/novnc "[::]:${WEBSOCKIFY_PORT}" "localhost:${RFB_PORT}" >> "$LOG_FILE" 2>&1 &
583
+ # The bind address is the install-time host decision, not a constant: on a
584
+ # host with no private LAN interface the noVNC page and the RFB port behind
585
+ # it must not be reachable from off the box. Task 2089.
586
+ resolve_websockify_bind
587
+ log "websockify-bind address=${WEBSOCKIFY_BIND} kind=${WEBSOCKIFY_BIND_KIND} port=${WEBSOCKIFY_PORT}"
588
+ websockify --web /usr/share/novnc "${WEBSOCKIFY_BIND}:${WEBSOCKIFY_PORT}" "localhost:${RFB_PORT}" >> "$LOG_FILE" 2>&1 &
545
589
 
546
590
  if wait_for_port "${WEBSOCKIFY_PORT}"; then
547
591
  log "websockify ready on :${WEBSOCKIFY_PORT} → localhost:${RFB_PORT}"
@@ -3970,7 +3970,7 @@ async function startConnection(accountId) {
3970
3970
  const hasAuth = await authExists(authDir);
3971
3971
  if (!hasAuth) {
3972
3972
  console.error(`${TAG17} no credentials for account=${accountId}`);
3973
- return;
3973
+ throw new Error(`no credentials for account=${accountId}`);
3974
3974
  }
3975
3975
  let platformAccountId;
3976
3976
  let houseAccountId;
@@ -4006,7 +4006,9 @@ async function startConnection(accountId) {
4006
4006
  lidMapping: null
4007
4007
  };
4008
4008
  connections.set(accountId, conn);
4009
- await connectWithReconnect(conn);
4009
+ void connectWithReconnect(conn).catch((err) => {
4010
+ console.error(`${TAG17} connection loop ended in error account=${accountId}: ${formatError(err)}`);
4011
+ });
4010
4012
  }
4011
4013
  async function stopConnection(accountId) {
4012
4014
  const conn = connections.get(accountId);
@@ -28,7 +28,7 @@ import {
28
28
  startConnection,
29
29
  stopConnection,
30
30
  storeMessage
31
- } from "./chunk-VUXKXOJU.js";
31
+ } from "./chunk-7MGR5M6G.js";
32
32
  import "./chunk-XCR5D2G3.js";
33
33
  import "./chunk-PFF6I7KP.js";
34
34
  export {
@@ -180,7 +180,7 @@ import {
180
180
  waitForConnection,
181
181
  warnNonUuidAccount,
182
182
  writeChatMetadataMerged
183
- } from "./chunk-VUXKXOJU.js";
183
+ } from "./chunk-7MGR5M6G.js";
184
184
  import {
185
185
  ACCOUNTS_DIR,
186
186
  BIN_DIR,
@@ -5008,7 +5008,7 @@ async function defaultHttpGet(url) {
5008
5008
  }
5009
5009
  }
5010
5010
  async function defaultResolve(accountId) {
5011
- const { resolveSocket: resolveSocket2 } = await import("./manager-4TJK3YOY.js");
5011
+ const { resolveSocket: resolveSocket2 } = await import("./manager-6XOWHREK.js");
5012
5012
  return resolveSocket2(accountId);
5013
5013
  }
5014
5014
  async function fetchAvatar(accountId, jid, deps = {}) {
@@ -6620,15 +6620,21 @@ app3.post("/disconnect", async (c) => {
6620
6620
  }
6621
6621
  });
6622
6622
  app3.post("/reconnect", async (c) => {
6623
+ let accountId = "(unresolved)";
6623
6624
  try {
6624
6625
  const body = await c.req.json().catch(() => ({}));
6625
- const accountId = validateAccountId(body.accountId);
6626
+ accountId = validateAccountId(body.accountId);
6626
6627
  const denial = selfOrHouseScopeDenial(c, accountId, "reconnect");
6627
6628
  if (denial) return denial;
6628
6629
  await startConnection(accountId);
6630
+ console.error(
6631
+ `[whatsapp:route] op=reconnect account=${accountId} started=true outcome=accepted reason=none`
6632
+ );
6629
6633
  return c.json({ reconnecting: true, accountId });
6630
6634
  } catch (err) {
6631
- console.error(`${TAG14} reconnect error: ${String(err)}`);
6635
+ console.error(
6636
+ `[whatsapp:route] op=reconnect account=${accountId} started=false outcome=refused reason=${String(err)}`
6637
+ );
6632
6638
  return c.json({ error: String(err) }, 500);
6633
6639
  }
6634
6640
  });
@@ -1,58 +0,0 @@
1
- # Search-surface parity contract
2
-
3
- Three operator/agent surfaces run the same `hybrid()` search engine
4
- (`platform/lib/graph-search`). The contract: **the same query yields the same
5
- node set on every surface** unless a surface declares an explicit, documented
6
- divergence.
7
-
8
- | Surface | Entry point | Labels default | Vector floor |
9
- |---|---|---|---|
10
- | Chat (agent recall) | `memory-search` MCP tool → `memorySearch()` | none set ⇒ all labels | `DEFAULT_VECTOR_THRESHOLD` (0.82) |
11
- | `/graph` admin page | `GET /api/admin/graph-search` | no chips ⇒ all labels; chips narrow | `DEFAULT_VECTOR_THRESHOLD` (0.82), `?threshold` overrides |
12
- | `/data` admin page | `GET /api/admin/graph-search?labels=FileArtifact` | `FileArtifact` only (files) | `DEFAULT_VECTOR_THRESHOLD` (0.82), `?threshold` overrides; show-all sends `?threshold=0` |
13
-
14
- ## The two parity rules (Task 635)
15
-
16
- 1. **All labels by default.** A caller that passes no labels (chat) or an empty
17
- chip set (`/graph`) searches every label — the route forwards `undefined` to
18
- `hybrid()`, the lib's no-label gate, which queries every vector index. The
19
- `*` wildcard is a separate explicit opt-in to the same all-labels path (no
20
- current caller sends it; `/data` sends a real `FileArtifact` label instead).
21
- `/graph` no longer rejects an empty chip set with a 400; chips, when present,
22
- narrow post-filter.
23
-
24
- 2. **One vector floor, defined once.** `DEFAULT_VECTOR_THRESHOLD = 0.82` lives
25
- in `platform/lib/graph-search/src/index.ts` and is the single source of
26
- truth. Each *surface* opts into it as its default; `hybrid()` itself keeps
27
- the `vectorThreshold === undefined ⇒ no floor` contract. Two literals of
28
- 0.82 in two files would be a drift vector — there is one constant, imported
29
- by both the route (`src`) and `memory-search` (`dist`, so the lib must be
30
- rebuilt for the memory plugin to see a change).
31
-
32
- ## Declared divergences (allowed, by explicit caller or post-filter)
33
-
34
- - **`/data` is files-only.** It sends `labels=FileArtifact` — a deliberate
35
- server-side caller filter to file nodes, not a parity break. (It also
36
- triggers the route's `FileArtifact` index-reconcile branch before searching.)
37
- - **`graph-subgraph` pivot mask.** `GET /api/admin/graph-subgraph?q=…` runs
38
- `hybrid()` with `expandHops: 0` and **no** vector floor. Its output is not a
39
- result list — it is intersected with a clicked node's neighbourhood to
40
- narrow a pivot. "Same query, same results" does not apply to an intersection
41
- mask, so it intentionally stays unthresholded.
42
- - **Post-search projections** — the chat public-twin property rewrite and the
43
- `/graph` Message→Conversation parent merge run after the engine returns and
44
- do not change which nodes the engine selected.
45
-
46
- ## Diagnostics
47
-
48
- ```
49
- logs-read.sh --tail server 100 | grep -E '\[graph-search\]'
50
- ```
51
-
52
- - `op=labels-default applied=all` — a no-chip `/graph` request took the
53
- all-labels path. Its absence on a no-labels request is the regression signal.
54
- - `labels=all` (query line) — no chips (all labels); `labels=*` — explicit
55
- wildcard opt-in; `labels=FileArtifact` — `/data` files search;
56
- `labels=Person,…` — narrowed chip search.
57
- - `threshold=0.82` on a default request; `threshold=off` only when an explicit
58
- `?threshold=0` was passed.