@addozhang/dsh-discord 0.1.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 (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `/model show` and the guarded `/model select` (design.md §7, tasks 10.2 +
3
+ * 10.3). A successful `session.selectModel` changes the addressed session AND
4
+ * attempts to persist that choice as the host default for future sessions, so
5
+ * mutation is restricted to the explicit global Host-operator allowlist —
6
+ * Guild-local administrators are denied. The confirmation states BOTH facts
7
+ * and nothing more: `applied-with-host-default`, `partial-session-only` (the
8
+ * session changed but persistence did not land), or a plain rejection/unknown.
9
+ * Catalog and reasoning-effort validation happen before any DSH call.
10
+ */
11
+ import type { AccessDecision } from '../policy/authorization.js';
12
+ export interface DshModelPort {
13
+ catalog(): Promise<{
14
+ outcome: 'completed';
15
+ providers: ReadonlyArray<{
16
+ provider: string;
17
+ models: ReadonlyArray<{
18
+ id: string;
19
+ reasonings: readonly string[];
20
+ }>;
21
+ }>;
22
+ } | {
23
+ outcome: 'failed';
24
+ } | {
25
+ outcome: 'unknown';
26
+ }>;
27
+ selectModel(request: {
28
+ sessionId: string;
29
+ modelId: string;
30
+ reasoning?: string;
31
+ }): Promise<{
32
+ outcome: 'completed';
33
+ sessionApplied: boolean;
34
+ defaultPersisted: boolean;
35
+ } | {
36
+ outcome: 'rejected';
37
+ reason: string;
38
+ } | {
39
+ outcome: 'unknown';
40
+ }>;
41
+ }
42
+ export type ModelCatalogView = {
43
+ outcome: 'ok';
44
+ providers: ReadonlyArray<{
45
+ provider: string;
46
+ models: ReadonlyArray<{
47
+ id: string;
48
+ reasonings: readonly string[];
49
+ }>;
50
+ }>;
51
+ } | {
52
+ outcome: 'failed';
53
+ reason: 'model-catalog-unavailable' | 'model-catalog-unknown';
54
+ };
55
+ export declare function showModelCatalog(port: DshModelPort, _request: {
56
+ sessionId: string;
57
+ }): Promise<ModelCatalogView>;
58
+ export type ModelSelectResult = {
59
+ outcome: 'applied-with-host-default';
60
+ sessionApplied: boolean;
61
+ defaultPersisted: boolean;
62
+ } | {
63
+ outcome: 'partial-session-only';
64
+ sessionApplied: boolean;
65
+ defaultPersisted: boolean;
66
+ } | {
67
+ outcome: 'rejected';
68
+ } | {
69
+ outcome: 'unknown';
70
+ } | {
71
+ outcome: 'refused';
72
+ reason: 'not-host-operator' | 'model-not-in-catalog' | 'invalid-reasoning-effort';
73
+ };
74
+ export declare function selectModel(port: DshModelPort, request: {
75
+ decision: AccessDecision;
76
+ sessionId: string;
77
+ modelId: string;
78
+ reasoning?: string | undefined;
79
+ }): Promise<ModelSelectResult>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `/model show` and the guarded `/model select` (design.md §7, tasks 10.2 +
3
+ * 10.3). A successful `session.selectModel` changes the addressed session AND
4
+ * attempts to persist that choice as the host default for future sessions, so
5
+ * mutation is restricted to the explicit global Host-operator allowlist —
6
+ * Guild-local administrators are denied. The confirmation states BOTH facts
7
+ * and nothing more: `applied-with-host-default`, `partial-session-only` (the
8
+ * session changed but persistence did not land), or a plain rejection/unknown.
9
+ * Catalog and reasoning-effort validation happen before any DSH call.
10
+ */
11
+ export async function showModelCatalog(port, _request) {
12
+ const catalog = await port.catalog();
13
+ if (catalog.outcome === 'failed')
14
+ return { outcome: 'failed', reason: 'model-catalog-unavailable' };
15
+ if (catalog.outcome === 'unknown')
16
+ return { outcome: 'failed', reason: 'model-catalog-unknown' };
17
+ return { outcome: 'ok', providers: catalog.providers };
18
+ }
19
+ export async function selectModel(port, request) {
20
+ // Host-operator authority ONLY: a Guild administrator is not enough,
21
+ // because the selection attempts to persist a host-wide default.
22
+ if (!request.decision.allowed || request.decision.level !== 'host-operator') {
23
+ return { outcome: 'refused', reason: 'not-host-operator' };
24
+ }
25
+ const catalog = await port.catalog();
26
+ if (catalog.outcome !== 'completed') {
27
+ return { outcome: 'refused', reason: 'model-not-in-catalog' };
28
+ }
29
+ const model = catalog.providers
30
+ .flatMap(provider => provider.models)
31
+ .find(candidate => candidate.id === request.modelId);
32
+ if (model === undefined) {
33
+ return { outcome: 'refused', reason: 'model-not-in-catalog' };
34
+ }
35
+ if (request.reasoning !== undefined && !model.reasonings.includes(request.reasoning)) {
36
+ return { outcome: 'refused', reason: 'invalid-reasoning-effort' };
37
+ }
38
+ const selected = await port.selectModel({
39
+ sessionId: request.sessionId,
40
+ modelId: request.modelId,
41
+ ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }),
42
+ });
43
+ if (selected.outcome !== 'completed') {
44
+ return selected.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
45
+ }
46
+ // State exactly what DSH proved — both facts, never overstated.
47
+ if (selected.sessionApplied && selected.defaultPersisted) {
48
+ return { outcome: 'applied-with-host-default', sessionApplied: true, defaultPersisted: true };
49
+ }
50
+ return { outcome: 'partial-session-only', sessionApplied: selected.sessionApplied, defaultPersisted: selected.defaultPersisted };
51
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Project-channel Agent Preset control (design.md §7, task 10.1). A preset is
3
+ * a complete Agent composition: the channel default persists independently of
4
+ * the workspace binding and is passed EXPLICITLY to future `session.create`
5
+ * calls only — existing sessions never change. Resetting omits the field so
6
+ * DSH applies its current host default. Selection validates against the host
7
+ * catalog; a missing or broken catalog refuses without writing.
8
+ */
9
+ export interface DshPresetPort {
10
+ listPresets(): Promise<{
11
+ outcome: 'completed';
12
+ presets: ReadonlyArray<{
13
+ id: string;
14
+ name: string;
15
+ }>;
16
+ } | {
17
+ outcome: 'failed';
18
+ } | {
19
+ outcome: 'unknown';
20
+ }>;
21
+ hostDefaultPreset(): Promise<{
22
+ outcome: 'completed';
23
+ presetId: string | undefined;
24
+ } | {
25
+ outcome: 'failed';
26
+ } | {
27
+ outcome: 'unknown';
28
+ }>;
29
+ }
30
+ export interface PresetChannelStore {
31
+ get(key: string): {
32
+ presetId: string;
33
+ } | undefined;
34
+ put(key: string, value: {
35
+ presetId: string;
36
+ }): Promise<void>;
37
+ delete(key: string): Promise<boolean>;
38
+ }
39
+ export interface PresetControlDeps {
40
+ presets: DshPresetPort;
41
+ channelStore: PresetChannelStore;
42
+ }
43
+ export type PresetSelectResult = {
44
+ outcome: 'selected';
45
+ presetId: string;
46
+ } | {
47
+ outcome: 'failed';
48
+ reason: 'preset-not-in-catalog' | 'preset-catalog-unavailable';
49
+ };
50
+ export type PresetShowResult = {
51
+ outcome: 'ok';
52
+ channelPreset: string | undefined;
53
+ hostDefault: string | undefined;
54
+ } | {
55
+ outcome: 'failed';
56
+ reason: 'preset-catalog-unavailable';
57
+ };
58
+ export type PresetResetResult = {
59
+ outcome: 'reset';
60
+ };
61
+ export declare function createPresetControl(deps: PresetControlDeps): {
62
+ show(input: {
63
+ guildId: string;
64
+ channelId: string;
65
+ }): Promise<PresetShowResult>;
66
+ select(input: {
67
+ guildId: string;
68
+ channelId: string;
69
+ presetId: string;
70
+ }): Promise<PresetSelectResult>;
71
+ reset(input: {
72
+ guildId: string;
73
+ channelId: string;
74
+ }): Promise<PresetResetResult>;
75
+ /** Merge the channel default into a future session.create request, if set. */
76
+ applyToSessionCreate<T extends {
77
+ sessionId: string;
78
+ }>(scope: {
79
+ guildId: string;
80
+ channelId: string;
81
+ }, createRequest: T): T & {
82
+ presetId?: string;
83
+ };
84
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Project-channel Agent Preset control (design.md §7, task 10.1). A preset is
3
+ * a complete Agent composition: the channel default persists independently of
4
+ * the workspace binding and is passed EXPLICITLY to future `session.create`
5
+ * calls only — existing sessions never change. Resetting omits the field so
6
+ * DSH applies its current host default. Selection validates against the host
7
+ * catalog; a missing or broken catalog refuses without writing.
8
+ */
9
+ const storeKey = (guildId, channelId) => `${guildId}:${channelId}`;
10
+ export function createPresetControl(deps) {
11
+ return {
12
+ async show(input) {
13
+ const hostDefault = await deps.presets.hostDefaultPreset();
14
+ if (hostDefault.outcome !== 'completed') {
15
+ return { outcome: 'failed', reason: 'preset-catalog-unavailable' };
16
+ }
17
+ return {
18
+ outcome: 'ok',
19
+ channelPreset: deps.channelStore.get(storeKey(input.guildId, input.channelId))?.presetId,
20
+ hostDefault: hostDefault.presetId,
21
+ };
22
+ },
23
+ async select(input) {
24
+ const catalog = await deps.presets.listPresets();
25
+ if (catalog.outcome !== 'completed') {
26
+ return { outcome: 'failed', reason: 'preset-catalog-unavailable' };
27
+ }
28
+ if (!catalog.presets.some(preset => preset.id === input.presetId)) {
29
+ return { outcome: 'failed', reason: 'preset-not-in-catalog' };
30
+ }
31
+ await deps.channelStore.put(storeKey(input.guildId, input.channelId), { presetId: input.presetId });
32
+ return { outcome: 'selected', presetId: input.presetId };
33
+ },
34
+ async reset(input) {
35
+ await deps.channelStore.delete(storeKey(input.guildId, input.channelId));
36
+ return { outcome: 'reset' };
37
+ },
38
+ applyToSessionCreate(scope, createRequest) {
39
+ const presetId = deps.channelStore.get(storeKey(scope.guildId, scope.channelId))?.presetId;
40
+ return presetId === undefined ? { ...createRequest } : { ...createRequest, presetId };
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `/project bind <workspace>` (design.md §4, §13). Workspace administrators
3
+ * bind the current project channel to a registered DSH Workspace. The flow is
4
+ * two-phase: `plan` authorizes, resolves the selection against the live
5
+ * catalog (a since-deleted Workspace is reported stale), and snapshots the
6
+ * existing binding's revision; `commit` writes through the revision-fenced
7
+ * store only after explicit confirmation. Everything is a value: a refused
8
+ * member, a stale workspace, a cancelled confirmation, and a lost race never
9
+ * leave a partial write behind.
10
+ */
11
+ import { type AccessDecision } from '../policy/authorization.js';
12
+ import type { ChannelBinding } from '../state/records.js';
13
+ import type { ChannelBindingScope } from '../state/domain.js';
14
+ import type { ChannelBindingStore } from '../state/bindings.js';
15
+ /** How the feature verifies a selection still exists in the DSH catalog. */
16
+ export interface WorkspaceResolver {
17
+ resolve(reference: string): Promise<{
18
+ outcome: 'found';
19
+ workspace: {
20
+ id: string;
21
+ title: string;
22
+ };
23
+ } | {
24
+ outcome: 'stale';
25
+ } | {
26
+ outcome: 'failed';
27
+ } | {
28
+ outcome: 'unknown';
29
+ }>;
30
+ }
31
+ export interface ProjectBindDeps {
32
+ resolver: WorkspaceResolver;
33
+ bindings: ChannelBindingStore;
34
+ }
35
+ export interface ProjectBindPlanRequest {
36
+ decision: AccessDecision;
37
+ scope: ChannelBindingScope;
38
+ /** Discord user id performing the bind (recorded as `boundBy`). */
39
+ actorId: string;
40
+ /** The opaque selection value (`ws:<id>`) chosen in the list. */
41
+ reference: string;
42
+ /** Whether the confirmation control was already answered affirmatively. */
43
+ confirmed: boolean;
44
+ }
45
+ export type ProjectBindPlan = {
46
+ outcome: 'planned';
47
+ scope: ChannelBindingScope;
48
+ actorId: string;
49
+ workspaceId: string;
50
+ /** Revision the commit must fence against; undefined for a fresh bind. */
51
+ previousRevision: number | undefined;
52
+ } | {
53
+ outcome: 'refused';
54
+ reason: 'not-authorized' | 'workspace-no-longer-registered' | 'workspace-catalog-unavailable';
55
+ };
56
+ export type ProjectBindResult = {
57
+ outcome: 'bound';
58
+ binding: ChannelBinding;
59
+ } | {
60
+ outcome: 'cancelled';
61
+ } | {
62
+ outcome: 'stale-revision';
63
+ };
64
+ export declare function createProjectBindFlow(deps: ProjectBindDeps): {
65
+ plan(request: ProjectBindPlanRequest): Promise<ProjectBindPlan>;
66
+ commit(plan: ProjectBindPlan, options?: {
67
+ cancelled?: boolean;
68
+ }): Promise<ProjectBindResult>;
69
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `/project bind <workspace>` (design.md §4, §13). Workspace administrators
3
+ * bind the current project channel to a registered DSH Workspace. The flow is
4
+ * two-phase: `plan` authorizes, resolves the selection against the live
5
+ * catalog (a since-deleted Workspace is reported stale), and snapshots the
6
+ * existing binding's revision; `commit` writes through the revision-fenced
7
+ * store only after explicit confirmation. Everything is a value: a refused
8
+ * member, a stale workspace, a cancelled confirmation, and a lost race never
9
+ * leave a partial write behind.
10
+ */
11
+ import { levelAtLeast } from '../policy/authorization.js';
12
+ export function createProjectBindFlow(deps) {
13
+ const channelKey = (scope) => `app:${scope.applicationId}:guild:${scope.guildId}:channel:${scope.channelId}`;
14
+ async function plan(request) {
15
+ // Authorization precedes every other step, including the catalog read.
16
+ if (!request.decision.allowed || !levelAtLeast(request.decision.level, 'workspace-administrator')) {
17
+ return { outcome: 'refused', reason: 'not-authorized' };
18
+ }
19
+ const resolved = await deps.resolver.resolve(request.reference);
20
+ if (resolved.outcome === 'stale') {
21
+ return { outcome: 'refused', reason: 'workspace-no-longer-registered' };
22
+ }
23
+ if (resolved.outcome !== 'found') {
24
+ return { outcome: 'refused', reason: 'workspace-catalog-unavailable' };
25
+ }
26
+ const current = deps.bindings.get(channelKey(request.scope));
27
+ return {
28
+ outcome: 'planned',
29
+ scope: request.scope,
30
+ actorId: request.actorId,
31
+ workspaceId: resolved.workspace.id,
32
+ previousRevision: current?.revision,
33
+ };
34
+ }
35
+ async function commit(plan, options = {}) {
36
+ if (plan.outcome !== 'planned')
37
+ return { outcome: 'cancelled' };
38
+ if (options.cancelled === true)
39
+ return { outcome: 'cancelled' };
40
+ const key = channelKey(plan.scope);
41
+ const record = {
42
+ workspaceId: plan.workspaceId,
43
+ boundBy: plan.actorId,
44
+ boundAtMs: Date.now(),
45
+ };
46
+ const result = plan.previousRevision === undefined
47
+ ? await deps.bindings.bind(key, record)
48
+ : await deps.bindings.bind(key, record, { expectedRevision: plan.previousRevision });
49
+ if (!result.ok) {
50
+ // already-bound cannot occur across a fenced expectedRevision commit;
51
+ // a lost race surfaces as stale-revision for the caller to re-plan.
52
+ return { outcome: 'stale-revision' };
53
+ }
54
+ return { outcome: 'bound', binding: result.binding };
55
+ }
56
+ return { plan, commit };
57
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `/project info` (design.md §3, §13). Any authorized member sees the
3
+ * Workspace identity and its canonical path — paths are not treated as
4
+ * sensitive for this self-hosted, trusted-Guild product — and the response
5
+ * is always ephemeral. A path never lands in durable channel metadata.
6
+ */
7
+ import type { AccessDecision } from '../policy/authorization.js';
8
+ import { type WorkspaceDetail, type WorkspaceEntry } from '../policy/disclosure.js';
9
+ export type ProjectInfoView = {
10
+ outcome: 'info';
11
+ workspace: WorkspaceDetail;
12
+ response: 'ephemeral';
13
+ } | {
14
+ outcome: 'refused';
15
+ reason: 'not-authorized';
16
+ };
17
+ /**
18
+ * Build the info view for one workspace. Authorization proves membership;
19
+ * the disclosure policy owns the exact rendered shape, and the response is
20
+ * always ephemeral.
21
+ */
22
+ export declare function projectInfo(input: {
23
+ decision: AccessDecision;
24
+ workspace: WorkspaceEntry;
25
+ }): ProjectInfoView;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `/project info` (design.md §3, §13). Any authorized member sees the
3
+ * Workspace identity and its canonical path — paths are not treated as
4
+ * sensitive for this self-hosted, trusted-Guild product — and the response
5
+ * is always ephemeral. A path never lands in durable channel metadata.
6
+ */
7
+ import { describeWorkspace } from '../policy/disclosure.js';
8
+ /**
9
+ * Build the info view for one workspace. Authorization proves membership;
10
+ * the disclosure policy owns the exact rendered shape, and the response is
11
+ * always ephemeral.
12
+ */
13
+ export function projectInfo(input) {
14
+ if (!input.decision.allowed) {
15
+ return { outcome: 'refused', reason: 'not-authorized' };
16
+ }
17
+ return {
18
+ outcome: 'info',
19
+ workspace: describeWorkspace(input.workspace, { includePath: true }),
20
+ response: 'ephemeral',
21
+ };
22
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `/project list` discovery (design.md §3, §13). The feature talks to DSH
3
+ * through one narrow port (satisfied by the apiProxy adapter in production,
4
+ * by fakes in tests): every registered Workspace is selectable, labels come
5
+ * from the least-disclosure policy (safe titles, opaque `ws:` references,
6
+ * duplicate disambiguation); per amended design §3 the bind autocomplete
7
+ * appends the abbreviated canonical path to each candidate label. This
8
+ * surface renders plain text, so it has no navigation: large catalogs are
9
+ * reported honestly — first page items plus the total count for the caller's
10
+ * truncation note — while the selector pagination helpers stay available to
11
+ * select-based surfaces.
12
+ * Catalog failures surface as sanitized outcomes — no raw provider detail
13
+ * ever reaches Discord.
14
+ */
15
+ import { type SelectorOption } from '../discord/selector.js';
16
+ /** How the feature reads the DSH Workspace catalog. */
17
+ export interface ProjectListPort {
18
+ listWorkspaces(): Promise<{
19
+ outcome: 'completed';
20
+ workspaces: ReadonlyArray<{
21
+ id: string;
22
+ title: string;
23
+ path?: string | undefined;
24
+ }>;
25
+ } | {
26
+ outcome: 'failed';
27
+ } | {
28
+ outcome: 'unknown';
29
+ }>;
30
+ }
31
+ export interface ProjectListRequest {
32
+ /** The unique id of this interaction, scoping page navigation values. */
33
+ selectionId: string;
34
+ query?: string | undefined;
35
+ page?: number | undefined;
36
+ }
37
+ export type ProjectListView = {
38
+ outcome: 'ok';
39
+ items: SelectorOption[];
40
+ /** The whole (query-filtered) catalog size, for truncation copy. */
41
+ totalCount: number;
42
+ pageIndex: number;
43
+ pageCount: number;
44
+ hasPrev: boolean;
45
+ hasNext: boolean;
46
+ navValues: {
47
+ prev?: string;
48
+ next?: string;
49
+ };
50
+ } | {
51
+ outcome: 'failed';
52
+ reason: 'workspace-catalog-unavailable' | 'workspace-catalog-unknown';
53
+ };
54
+ /**
55
+ * Build the `/project list` view: fetch the catalog, sanitize and
56
+ * disambiguate labels, apply the query, and page the result.
57
+ */
58
+ export declare function createProjectListView(port: ProjectListPort, request: ProjectListRequest): Promise<ProjectListView>;
59
+ /** One Discord autocomplete choice: display label plus the opaque value. */
60
+ export interface AutocompleteChoice {
61
+ name: string;
62
+ value: string;
63
+ }
64
+ /**
65
+ * Build the autocomplete choices for a workspace reference option (the
66
+ * Live-candidates pattern: the option lists live candidates as you type,
67
+ * so no id is ever copy-pasted). Labels carry the same sanitized base as
68
+ * `/project list` plus the abbreviated canonical path when the
69
+ * Host supplies one; the query narrows via the shared filter and matches
70
+ * path text too. Labels exceeding Discord's 100-character choice-name
71
+ * limit fall back to the bare sanitized label rather than truncating.
72
+ */
73
+ export declare function workspaceAutocompleteChoices(catalog: ReadonlyArray<{
74
+ id: string;
75
+ title: string;
76
+ path?: string | undefined;
77
+ }>, query: string, options?: {
78
+ home?: string;
79
+ }): AutocompleteChoice[];
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `/project list` discovery (design.md §3, §13). The feature talks to DSH
3
+ * through one narrow port (satisfied by the apiProxy adapter in production,
4
+ * by fakes in tests): every registered Workspace is selectable, labels come
5
+ * from the least-disclosure policy (safe titles, opaque `ws:` references,
6
+ * duplicate disambiguation); per amended design §3 the bind autocomplete
7
+ * appends the abbreviated canonical path to each candidate label. This
8
+ * surface renders plain text, so it has no navigation: large catalogs are
9
+ * reported honestly — first page items plus the total count for the caller's
10
+ * truncation note — while the selector pagination helpers stay available to
11
+ * select-based surfaces.
12
+ * Catalog failures surface as sanitized outcomes — no raw provider detail
13
+ * ever reaches Discord.
14
+ */
15
+ import { filterAutocomplete, paginateSelector } from '../discord/selector.js';
16
+ import { abbreviatePath, safeTitle, workspaceLabels, workspaceReference } from '../policy/disclosure.js';
17
+ /**
18
+ * Build the `/project list` view: fetch the catalog, sanitize and
19
+ * disambiguate labels, apply the query, and page the result.
20
+ */
21
+ export async function createProjectListView(port, request) {
22
+ const catalog = await port.listWorkspaces();
23
+ if (catalog.outcome === 'failed') {
24
+ return { outcome: 'failed', reason: 'workspace-catalog-unavailable' };
25
+ }
26
+ if (catalog.outcome === 'unknown') {
27
+ return { outcome: 'failed', reason: 'workspace-catalog-unknown' };
28
+ }
29
+ // Down-project to the two display fields before any label work, so the
30
+ // canonical path is structurally out of every label below.
31
+ const entries = catalog.workspaces.map(workspace => ({
32
+ id: workspace.id,
33
+ title: safeTitle(workspace.title),
34
+ }));
35
+ const labeled = workspaceLabels(entries);
36
+ const query = request.query ?? '';
37
+ const selectable = labeled.map(entry => ({
38
+ label: entry.label,
39
+ value: entry.id,
40
+ }));
41
+ const matches = query.trim() === ''
42
+ ? selectable
43
+ : filterAutocomplete(selectable, query.trim(), Number.POSITIVE_INFINITY);
44
+ const byId = new Map(labeled.map(entry => [entry.id, entry]));
45
+ const options = [];
46
+ for (const match of matches) {
47
+ const entry = byId.get(match.value);
48
+ if (entry === undefined)
49
+ continue;
50
+ options.push({ label: entry.label, value: workspaceReference(entry.id) });
51
+ }
52
+ const page = paginateSelector(options, request.page ?? 0, `${request.selectionId}:page`);
53
+ return {
54
+ outcome: 'ok',
55
+ items: page.items,
56
+ totalCount: options.length,
57
+ pageIndex: page.pageIndex,
58
+ pageCount: page.pageCount,
59
+ hasPrev: page.hasPrev,
60
+ hasNext: page.hasNext,
61
+ navValues: page.navValues,
62
+ };
63
+ }
64
+ /**
65
+ * Build the autocomplete choices for a workspace reference option (the
66
+ * Live-candidates pattern: the option lists live candidates as you type,
67
+ * so no id is ever copy-pasted). Labels carry the same sanitized base as
68
+ * `/project list` plus the abbreviated canonical path when the
69
+ * Host supplies one; the query narrows via the shared filter and matches
70
+ * path text too. Labels exceeding Discord's 100-character choice-name
71
+ * limit fall back to the bare sanitized label rather than truncating.
72
+ */
73
+ export function workspaceAutocompleteChoices(catalog, query, options = {}) {
74
+ const entries = catalog.map(workspace => ({
75
+ id: workspace.id,
76
+ title: safeTitle(workspace.title),
77
+ }));
78
+ const labeled = workspaceLabels(entries);
79
+ const selectable = labeled.map((entry, index) => ({
80
+ label: withAbbreviatedPath(entry.label, catalog[index]?.path, options.home),
81
+ value: workspaceReference(entry.id),
82
+ }));
83
+ const matches = filterAutocomplete(selectable, query, Number.POSITIVE_INFINITY);
84
+ return matches.map(match => ({ name: match.label, value: match.value }));
85
+ }
86
+ /** Discord caps autocomplete choice names at 100 characters. */
87
+ const DISCORD_CHOICE_NAME_MAX = 100;
88
+ function withAbbreviatedPath(label, path, home) {
89
+ if (path === undefined || path === '')
90
+ return label;
91
+ const candidate = `${label} (${abbreviatePath(path, home)})`;
92
+ return candidate.length <= DISCORD_CHOICE_NAME_MAX ? candidate : label;
93
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * At-most-once prompt submission (design.md §5, §10, task 8.4). Every prompt
3
+ * carries a stable request id claimed before the DSH call. A same-id/same-
4
+ * hash delivery never resubmits; a different hash conflicts. A rejection ends
5
+ * that intent — an explicit user retry mints a NEW intent. An unknown
6
+ * admission is recorded as `unknown` and NEVER retried automatically; the
7
+ * reconciler checks DSH history/queue evidence, and an explicit retry (new
8
+ * intent) is the user's deliberate act.
9
+ */
10
+ import { type IntentStore } from '../state/intents.js';
11
+ /** The DSH prompt surface the flow needs (session.prompt in production). */
12
+ export interface DshPromptPort {
13
+ submit(request: {
14
+ requestId: string;
15
+ sessionId: string;
16
+ prompt: string;
17
+ mode: 'queue';
18
+ }): Promise<{
19
+ outcome: 'accepted';
20
+ } | {
21
+ outcome: 'rejected';
22
+ reason: string;
23
+ } | {
24
+ outcome: 'unknown';
25
+ }>;
26
+ }
27
+ export interface PromptSubmissionDeps {
28
+ prompts: DshPromptPort;
29
+ intents: IntentStore;
30
+ nowMs: () => number;
31
+ }
32
+ export type PromptSubmissionResult = {
33
+ outcome: 'accepted';
34
+ } | {
35
+ outcome: 'rejected';
36
+ } | {
37
+ outcome: 'unknown';
38
+ } | {
39
+ outcome: 'already-submitted';
40
+ } | {
41
+ outcome: 'conflict';
42
+ };
43
+ export interface PromptRequest {
44
+ requestId: string;
45
+ sessionId: string;
46
+ prompt: string;
47
+ }
48
+ export declare function createPromptSubmissionFlow(deps: PromptSubmissionDeps): {
49
+ submitOnce(request: PromptRequest): Promise<PromptSubmissionResult>;
50
+ retry(request: PromptRequest, options: {
51
+ newRequestId: string;
52
+ }): Promise<PromptSubmissionResult>;
53
+ };