@wrongstack/requirement-intake 0.299.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.
package/dist/index.js ADDED
@@ -0,0 +1,1805 @@
1
+ // src/constants.ts
2
+ var INTAKE_ID_PREFIX = "reqi_";
3
+ var ANSWER_ID_PREFIX = "ans_";
4
+ var ATTACHMENT_ID_PREFIX = "attach_";
5
+ var RELATED_RESOURCE_ID_PREFIX = "relres_";
6
+ var QUESTION_ID_PREFIX = "q_";
7
+ var SUGGESTION_ID_PREFIX = "sug_";
8
+ var REQUEST_TYPES = [
9
+ "feature",
10
+ "bug_fix",
11
+ "refactor",
12
+ "performance",
13
+ "security",
14
+ "ui_change",
15
+ "api_change",
16
+ "infrastructure",
17
+ "migration",
18
+ "testing",
19
+ "documentation",
20
+ "maintenance",
21
+ "other",
22
+ "unspecified"
23
+ ];
24
+ var INTAKE_STATUSES = [
25
+ "draft",
26
+ "collecting_information",
27
+ "submitted",
28
+ "cancelled",
29
+ "archived"
30
+ ];
31
+ var INTAKE_PRIORITIES = ["unspecified", "low", "medium", "high", "critical"];
32
+ var INTAKE_FIELD_SOURCES = ["user", "llm", "deterministic", "system"];
33
+ var INTAKE_FIELDS = [
34
+ "title",
35
+ "normalized_summary",
36
+ "request_type",
37
+ "priority",
38
+ "business_goal",
39
+ "target_users",
40
+ "expected_outcome",
41
+ "scope_notes",
42
+ "constraints",
43
+ "provided_context",
44
+ "attachments",
45
+ "related_resources"
46
+ ];
47
+ var INTAKE_ATTACHMENT_KINDS = ["file", "image", "document", "link", "other"];
48
+ var RELATED_RESOURCE_KINDS = ["spec", "issue", "pr", "doc", "url", "other"];
49
+ var INTAKE_QUESTION_STATUSES = ["unanswered", "answered", "skipped"];
50
+ var SUGGESTION_STATUSES = ["pending", "accepted", "rejected"];
51
+ var SUGGESTION_KINDS = [
52
+ "title",
53
+ "summary",
54
+ "request_type",
55
+ "priority",
56
+ "question",
57
+ "constraint",
58
+ "target_user",
59
+ "outcome"
60
+ ];
61
+ var MAX_REQUEST_LENGTH = 1e5;
62
+ var MAX_TITLE_LENGTH = 200;
63
+ var MAX_SUMMARY_LENGTH = 2e3;
64
+ var MAX_STRING_FIELD_LENGTH = 5e3;
65
+ var MAX_ARRAY_ITEMS = 50;
66
+ var MAX_ATTACHMENTS = 20;
67
+ var MAX_RELATED_RESOURCES = 50;
68
+ var MAX_METADATA_ENTRIES = 50;
69
+ var MAX_METADATA_BYTES = 64 * 1024;
70
+ var MAX_ANSWER_LENGTH = 5e4;
71
+ var MAX_IDEMPOTENCY_KEY_LENGTH = 128;
72
+ var MAX_REFERENCE_LENGTH = 500;
73
+ var MAX_QUESTION_LENGTH = 500;
74
+ var MAX_HISTORY_ENTRIES = 200;
75
+ var MAX_SUGGESTIONS = 100;
76
+ var DEFAULT_INTAKE_QUESTIONS = [
77
+ { field: "description_scope", question: "What should be changed or created?", required: true },
78
+ { field: "business_goal", question: "What problem should this request solve?", required: true },
79
+ { field: "target_users", question: "Who will use this functionality?", required: false },
80
+ { field: "expected_outcome", question: "What outcome is expected?", required: false },
81
+ {
82
+ field: "project_component",
83
+ question: "Which project or component is affected?",
84
+ required: false
85
+ },
86
+ { field: "constraints", question: "Are there known constraints?", required: false },
87
+ {
88
+ field: "related_resources",
89
+ question: "Is there an existing implementation or reference?",
90
+ required: false
91
+ },
92
+ { field: "priority", question: "Is there a desired priority?", required: false },
93
+ {
94
+ field: "attachments",
95
+ question: "Are any files, screenshots, documents, or links relevant?",
96
+ required: false
97
+ }
98
+ ];
99
+
100
+ // src/types.ts
101
+ var INTAKE_EVENT_NAMES = [
102
+ "RequirementIntakeCreated",
103
+ "RequirementIntakeUpdated",
104
+ "RequirementIntakeInformationRequested",
105
+ "RequirementIntakeSubmitted",
106
+ "RequirementIntakeCancelled",
107
+ "RequirementIntakeArchived"
108
+ ];
109
+
110
+ // src/errors.ts
111
+ var IntakeError = class extends Error {
112
+ code;
113
+ constructor(code, message, options) {
114
+ super(message, options);
115
+ this.name = "IntakeError";
116
+ this.code = code;
117
+ }
118
+ };
119
+ var IntakeValidationError = class extends IntakeError {
120
+ issues;
121
+ constructor(issues, message) {
122
+ super(
123
+ "INTAKE_VALIDATION_ERROR",
124
+ message ?? `Requirement intake validation failed (${issues.length} issue${issues.length === 1 ? "" : "s"})`
125
+ );
126
+ this.name = "IntakeValidationError";
127
+ this.issues = issues;
128
+ }
129
+ };
130
+ var IntakeNotFoundError = class extends IntakeError {
131
+ constructor(id) {
132
+ super("INTAKE_NOT_FOUND", `Requirement intake record not found: ${id}`);
133
+ this.name = "IntakeNotFoundError";
134
+ }
135
+ };
136
+ var IntakeStateTransitionError = class extends IntakeError {
137
+ constructor(from, to) {
138
+ super(
139
+ "INTAKE_INVALID_TRANSITION",
140
+ `Invalid requirement intake status transition: ${from} \u2192 ${to}`
141
+ );
142
+ this.name = "IntakeStateTransitionError";
143
+ }
144
+ };
145
+ var IntakeStatusLockedError = class extends IntakeError {
146
+ constructor(id, status, action) {
147
+ super(
148
+ "INTAKE_STATUS_LOCKED",
149
+ `Requirement intake ${id} is ${status} and cannot be modified via ${action}`
150
+ );
151
+ this.name = "IntakeStatusLockedError";
152
+ }
153
+ };
154
+ var IntakeConflictError = class extends IntakeError {
155
+ constructor(id, expectedVersion, actualVersion) {
156
+ super(
157
+ "INTAKE_CONFLICT",
158
+ `Requirement intake ${id} was modified concurrently (expected version ${expectedVersion}, found ${actualVersion})`
159
+ );
160
+ this.name = "IntakeConflictError";
161
+ }
162
+ };
163
+ var IntakeAuthorizationError = class extends IntakeError {
164
+ constructor(operation, actorId, projectId) {
165
+ super(
166
+ "INTAKE_UNAUTHORIZED",
167
+ `Actor ${actorId} is not authorized to ${operation} requirement intakes for project ${projectId}`
168
+ );
169
+ this.name = "IntakeAuthorizationError";
170
+ }
171
+ };
172
+ var IntakeSuggestionError = class extends IntakeError {
173
+ constructor(message, options) {
174
+ super("INTAKE_SUGGESTION_ERROR", message, options);
175
+ this.name = "IntakeSuggestionError";
176
+ }
177
+ };
178
+
179
+ // src/validation.ts
180
+ import { z } from "zod";
181
+ var requestTypeSchema = z.enum(REQUEST_TYPES);
182
+ var prioritySchema = z.enum(INTAKE_PRIORITIES);
183
+ var intakeQuestionStatusSchema = z.enum(INTAKE_QUESTION_STATUSES);
184
+ var fieldSourceSchema = z.enum(["user", "llm", "deterministic", "system"]);
185
+ var httpUrlSchema = z.string().max(MAX_REFERENCE_LENGTH).refine((value) => {
186
+ try {
187
+ const url = new URL(value);
188
+ return url.protocol === "http:" || url.protocol === "https:";
189
+ } catch {
190
+ return false;
191
+ }
192
+ }, "must be a valid http(s) URL");
193
+ var idSchema = z.string().min(1).max(128).refine((value) => value.trim().length > 0, "must not be blank");
194
+ var nonBlankString = (max, label) => z.string().max(max, `${label} exceeds the maximum length of ${max} characters`).refine((value) => value.trim().length > 0, `${label} must not be empty or whitespace-only`);
195
+ var attachmentInputSchema = z.object({
196
+ name: nonBlankString(200, "attachment name"),
197
+ kind: z.enum(INTAKE_ATTACHMENT_KINDS),
198
+ path: z.string().max(MAX_REFERENCE_LENGTH).optional(),
199
+ url: httpUrlSchema.optional(),
200
+ sizeBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
201
+ mimeType: z.string().max(100).optional()
202
+ }).refine((value) => value.path === void 0 !== (value.url === void 0), {
203
+ message: "exactly one of path or url is required"
204
+ });
205
+ var relatedResourceInputSchema = z.object({
206
+ kind: z.enum(RELATED_RESOURCE_KINDS),
207
+ reference: nonBlankString(MAX_REFERENCE_LENGTH, "related resource reference"),
208
+ title: z.string().max(200).optional()
209
+ });
210
+ var questionTemplateInputSchema = z.object({
211
+ field: nonBlankString(64, "question field"),
212
+ question: nonBlankString(MAX_QUESTION_LENGTH, "question text"),
213
+ required: z.boolean().optional()
214
+ });
215
+ var metadataSchema = z.record(z.string().max(64), z.unknown()).superRefine((value, ctx) => {
216
+ const keys = Object.keys(value);
217
+ if (keys.length > MAX_METADATA_ENTRIES) {
218
+ ctx.addIssue({
219
+ code: "custom",
220
+ path: [],
221
+ message: `metadata exceeds the maximum of ${MAX_METADATA_ENTRIES} entries`
222
+ });
223
+ }
224
+ let bytes = 0;
225
+ try {
226
+ bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
227
+ } catch {
228
+ ctx.addIssue({ code: "custom", path: [], message: "metadata must be JSON-serializable" });
229
+ return;
230
+ }
231
+ if (bytes > MAX_METADATA_BYTES) {
232
+ ctx.addIssue({
233
+ code: "custom",
234
+ path: [],
235
+ message: `metadata exceeds the maximum size of ${MAX_METADATA_BYTES} bytes`
236
+ });
237
+ }
238
+ });
239
+ var optionalString = (max) => z.string().max(max).optional();
240
+ var optionalStringArray = z.array(z.string().trim().min(1).max(MAX_STRING_FIELD_LENGTH)).max(MAX_ARRAY_ITEMS).optional();
241
+ var optionalAttachmentArray = z.array(attachmentInputSchema).max(MAX_ATTACHMENTS).optional();
242
+ var optionalRelatedResourceArray = z.array(relatedResourceInputSchema).max(MAX_RELATED_RESOURCES).optional();
243
+ var createIntakeSchema = z.object({
244
+ projectId: idSchema,
245
+ originalRequest: nonBlankString(MAX_REQUEST_LENGTH, "originalRequest"),
246
+ title: optionalString(MAX_TITLE_LENGTH),
247
+ requestType: z.string().max(64).optional(),
248
+ priority: prioritySchema.optional(),
249
+ requestedBy: idSchema,
250
+ businessGoal: optionalString(MAX_STRING_FIELD_LENGTH),
251
+ targetUsers: optionalStringArray,
252
+ expectedOutcome: optionalString(MAX_STRING_FIELD_LENGTH),
253
+ scopeNotes: optionalString(MAX_STRING_FIELD_LENGTH),
254
+ constraints: optionalStringArray,
255
+ providedContext: optionalStringArray,
256
+ attachments: optionalAttachmentArray,
257
+ relatedResources: optionalRelatedResourceArray,
258
+ metadata: metadataSchema.optional(),
259
+ idempotencyKey: z.string().trim().max(MAX_IDEMPOTENCY_KEY_LENGTH).optional(),
260
+ knownFields: z.array(z.string().trim().min(1).max(64)).max(MAX_ARRAY_ITEMS).optional(),
261
+ questions: z.array(questionTemplateInputSchema).max(50).optional()
262
+ }).strict();
263
+ var updateIntakeSchema = z.object({
264
+ title: optionalString(MAX_TITLE_LENGTH),
265
+ requestType: z.string().max(64).optional(),
266
+ priority: prioritySchema.optional(),
267
+ businessGoal: optionalString(MAX_STRING_FIELD_LENGTH),
268
+ targetUsers: optionalStringArray,
269
+ expectedOutcome: optionalString(MAX_STRING_FIELD_LENGTH),
270
+ scopeNotes: optionalString(MAX_STRING_FIELD_LENGTH),
271
+ constraints: optionalStringArray,
272
+ providedContext: optionalStringArray,
273
+ metadata: metadataSchema.optional()
274
+ }).strict();
275
+ var answerInputSchema = z.object({
276
+ field: nonBlankString(64, "answer field"),
277
+ answer: nonBlankString(MAX_ANSWER_LENGTH, "answer").max(
278
+ MAX_ANSWER_LENGTH,
279
+ `answer exceeds the maximum length of ${MAX_ANSWER_LENGTH} characters`
280
+ ),
281
+ question: z.string().max(MAX_QUESTION_LENGTH).optional()
282
+ });
283
+ var attachResourceInputSchema = z.object({
284
+ attachment: attachmentInputSchema.optional(),
285
+ relatedResource: relatedResourceInputSchema.optional()
286
+ }).refine((value) => value.attachment === void 0 !== (value.relatedResource === void 0), {
287
+ message: "exactly one of attachment or relatedResource must be provided"
288
+ });
289
+ function normalizeRequestType(value) {
290
+ if (typeof value !== "string") return "unspecified";
291
+ const trimmed = value.trim().toLowerCase();
292
+ if (trimmed.length === 0) return "unspecified";
293
+ if (REQUEST_TYPES.includes(trimmed)) return trimmed;
294
+ return "other";
295
+ }
296
+ function isBlank(value) {
297
+ return value.trim().length === 0;
298
+ }
299
+ function parseWithIssues(schema, data, scope) {
300
+ const result = schema.safeParse(data);
301
+ if (result.success) return result.data;
302
+ const issues = result.error.issues.map((issue) => {
303
+ const path2 = issue.path.join(".");
304
+ return {
305
+ field: path2 === "" ? scope : `${scope}.${path2}`,
306
+ message: issue.message
307
+ };
308
+ });
309
+ throw new IntakeValidationError(issues);
310
+ }
311
+ function validateCreateInput(data) {
312
+ return parseWithIssues(createIntakeSchema, data, "createIntake");
313
+ }
314
+ function validateUpdateInput(data) {
315
+ return parseWithIssues(updateIntakeSchema, data, "updateIntake");
316
+ }
317
+ function validateAnswerInput(data) {
318
+ return parseWithIssues(answerInputSchema, data, "addAnswer");
319
+ }
320
+ function validateAttachResourceInput(data) {
321
+ return parseWithIssues(attachResourceInputSchema, data, "attachResource");
322
+ }
323
+ function validateAttachmentInput(data) {
324
+ return parseWithIssues(attachmentInputSchema, data, "attachment");
325
+ }
326
+ function validateRelatedResourceInput(data) {
327
+ return parseWithIssues(relatedResourceInputSchema, data, "relatedResource");
328
+ }
329
+ function validateQuestionTemplateInput(data) {
330
+ return parseWithIssues(questionTemplateInputSchema, data, "question");
331
+ }
332
+ function validateFieldSource(value) {
333
+ const parsed = fieldSourceSchema.safeParse(value);
334
+ if (parsed.success) return parsed.data;
335
+ throw new IntakeValidationError([{ field: "source", message: "invalid field source" }]);
336
+ }
337
+ function deterministicSummary(originalRequest, maxLength = 240) {
338
+ const collapsed = originalRequest.replace(/\s+/g, " ").trim();
339
+ if (collapsed.length <= maxLength) return collapsed;
340
+ return `${collapsed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
341
+ }
342
+ function deterministicTitle(originalRequest, maxLength = MAX_TITLE_LENGTH) {
343
+ const firstLine = originalRequest.split(/\r?\n/, 1)[0]?.trim() ?? "";
344
+ if (firstLine.length === 0) return "Untitled request";
345
+ if (firstLine.length <= maxLength) return firstLine;
346
+ return `${firstLine.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
347
+ }
348
+
349
+ // src/lifecycle.ts
350
+ var ALLOWED_TRANSITIONS = {
351
+ draft: ["collecting_information", "submitted", "cancelled"],
352
+ collecting_information: ["submitted", "cancelled"],
353
+ submitted: ["archived"],
354
+ cancelled: ["archived"],
355
+ archived: []
356
+ };
357
+ var MUTABLE_STATUSES = ["draft", "collecting_information"];
358
+ function canTransition(from, to) {
359
+ return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
360
+ }
361
+ function assertTransition(from, to) {
362
+ if (!canTransition(from, to)) {
363
+ throw new IntakeStateTransitionError(from, to);
364
+ }
365
+ }
366
+ function isMutableStatus(status) {
367
+ return MUTABLE_STATUSES.includes(status);
368
+ }
369
+ function isTerminalStatus(status) {
370
+ return status === "submitted" || status === "cancelled" || status === "archived";
371
+ }
372
+ function isKnownStatus(value) {
373
+ return INTAKE_STATUSES.includes(value);
374
+ }
375
+
376
+ // src/questions.ts
377
+ import { ulid } from "@wrongstack/core/utils";
378
+ var FIELD_TO_INPUT_PROPERTY = {
379
+ description_scope: () => true,
380
+ // always answered by the original request
381
+ business_goal: (input) => input.businessGoal !== void 0 && input.businessGoal.trim().length > 0,
382
+ target_users: (input) => (input.targetUsers?.length ?? 0) > 0,
383
+ expected_outcome: (input) => input.expectedOutcome !== void 0 && input.expectedOutcome.trim().length > 0,
384
+ project_component: (input) => (input.providedContext?.length ?? 0) > 0,
385
+ constraints: (input) => (input.constraints?.length ?? 0) > 0,
386
+ related_resources: (input) => (input.relatedResources?.length ?? 0) > 0,
387
+ priority: (input) => input.priority !== void 0 && input.priority !== "unspecified",
388
+ attachments: (input) => (input.attachments?.length ?? 0) > 0
389
+ };
390
+ function makeQuestion(template, order, skipped) {
391
+ return {
392
+ id: `${QUESTION_ID_PREFIX}${ulid()}`,
393
+ field: template.field,
394
+ question: template.question,
395
+ required: template.required,
396
+ status: skipped ? "skipped" : "unanswered",
397
+ order
398
+ };
399
+ }
400
+ function buildInitialQuestions(input, catalog = DEFAULT_INTAKE_QUESTIONS) {
401
+ const known = new Set((input.knownFields ?? []).map((field) => field.trim()));
402
+ return catalog.map((template, index) => {
403
+ const answeredByInput = FIELD_TO_INPUT_PROPERTY[template.field]?.(input) ?? false;
404
+ const skipped = answeredByInput || known.has(template.field);
405
+ return makeQuestion(template, index, skipped);
406
+ });
407
+ }
408
+ function pendingQuestions(record) {
409
+ return record.questions.filter((question) => question.status === "unanswered").sort((a, b) => a.order - b.order);
410
+ }
411
+ function upsertQuestion(record, template) {
412
+ const existing = record.questions.find(
413
+ (question) => question.field === template.field && question.status !== "skipped"
414
+ );
415
+ if (existing) return false;
416
+ record.questions.push(makeQuestion(template, record.questions.length, false));
417
+ return true;
418
+ }
419
+
420
+ // src/authorization.ts
421
+ var INTAKE_OPERATIONS = [
422
+ "create",
423
+ "read",
424
+ "list",
425
+ "update",
426
+ "answer",
427
+ "attach",
428
+ "suggest",
429
+ "accept_suggestion",
430
+ "reject_suggestion",
431
+ "submit",
432
+ "cancel",
433
+ "archive"
434
+ ];
435
+ var AllowAllIntakeAuthorizer = class {
436
+ isAllowed(_operation, _ctx, _record) {
437
+ return true;
438
+ }
439
+ };
440
+ var DenyAllIntakeAuthorizer = class {
441
+ isAllowed(_operation, _ctx, _record) {
442
+ return false;
443
+ }
444
+ };
445
+ var ProjectMembershipIntakeAuthorizer = class {
446
+ projectsOf;
447
+ ownerOnly;
448
+ constructor(options) {
449
+ this.projectsOf = options.projectsOf;
450
+ this.ownerOnly = options.ownerOnlyOperations ?? /* @__PURE__ */ new Set();
451
+ }
452
+ async isAllowed(operation, ctx, record) {
453
+ if (record && record.projectId !== ctx.projectId) return false;
454
+ const memberships = await this.projectsOf(ctx.id, ctx.type);
455
+ if (!memberships.has(ctx.projectId)) return false;
456
+ if (this.ownerOnly.has(operation) && record) {
457
+ return record.requestedBy === ctx.id;
458
+ }
459
+ return true;
460
+ }
461
+ };
462
+
463
+ // src/logger.ts
464
+ var NoopIntakeLogger = class {
465
+ info(_scope, _message, _fields) {
466
+ }
467
+ warn(_scope, _message, _fields) {
468
+ }
469
+ error(_scope, _message, _fields) {
470
+ }
471
+ };
472
+ var InMemoryIntakeLogger = class {
473
+ entries = [];
474
+ info(scope, message, fields) {
475
+ this.entries.push({
476
+ level: "info",
477
+ scope,
478
+ message,
479
+ ...fields !== void 0 ? { fields } : {}
480
+ });
481
+ }
482
+ warn(scope, message, fields) {
483
+ this.entries.push({
484
+ level: "warn",
485
+ scope,
486
+ message,
487
+ ...fields !== void 0 ? { fields } : {}
488
+ });
489
+ }
490
+ error(scope, message, fields) {
491
+ this.entries.push({
492
+ level: "error",
493
+ scope,
494
+ message,
495
+ ...fields !== void 0 ? { fields } : {}
496
+ });
497
+ }
498
+ };
499
+
500
+ // src/metrics.ts
501
+ var INTAKE_COUNTERS = [
502
+ "intake.created",
503
+ "intake.submitted",
504
+ "intake.cancelled",
505
+ "intake.archived",
506
+ "intake.duplicate_create",
507
+ "intake.duplicate_submit",
508
+ "intake.validation_failure",
509
+ "intake.suggestions.requested",
510
+ "intake.suggestions.succeeded",
511
+ "intake.suggestions.failed",
512
+ "intake.unauthorized_attempt"
513
+ ];
514
+ var INTAKE_TIMERS = ["intake.time_to_submit"];
515
+ var NoopIntakeMetrics = class {
516
+ increment(_counter, _by, _labels) {
517
+ }
518
+ recordDuration(_timer, _milliseconds) {
519
+ }
520
+ };
521
+ var InMemoryIntakeMetrics = class {
522
+ counters = /* @__PURE__ */ new Map();
523
+ durations = /* @__PURE__ */ new Map();
524
+ increment(counter, by = 1, _labels) {
525
+ this.counters.set(counter, (this.counters.get(counter) ?? 0) + by);
526
+ }
527
+ recordDuration(timer, milliseconds) {
528
+ const bucket = this.durations.get(timer) ?? [];
529
+ bucket.push(milliseconds);
530
+ this.durations.set(timer, bucket);
531
+ }
532
+ count(counter) {
533
+ return this.counters.get(counter) ?? 0;
534
+ }
535
+ /** Sum of recorded durations for a timer, or undefined when none recorded. */
536
+ durationSum(timer) {
537
+ const bucket = this.durations.get(timer);
538
+ if (!bucket || bucket.length === 0) return void 0;
539
+ return bucket.reduce((total, value) => total + value, 0);
540
+ }
541
+ };
542
+
543
+ // src/events.ts
544
+ var MAX_LISTENERS = 200;
545
+ var IntakeEventEmitter = class {
546
+ listeners = /* @__PURE__ */ new Set();
547
+ /** Publish an event. Listener errors are swallowed so one bad listener cannot break others. */
548
+ emit(event, data) {
549
+ const payload = { event, timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...data };
550
+ for (const listener of [...this.listeners]) {
551
+ try {
552
+ listener(payload);
553
+ } catch {
554
+ }
555
+ }
556
+ }
557
+ /** Subscribe; returns a disposer. Past the cap, returns a no-op disposer. */
558
+ subscribe(listener) {
559
+ if (this.listeners.size >= MAX_LISTENERS) {
560
+ const message = "Requirement intake event-emitter listener limit reached \u2014 callers must dispose subscriptions";
561
+ if (typeof process !== "undefined" && typeof process.emitWarning === "function") {
562
+ process.emitWarning(message, "IntakeEventEmitterWarning");
563
+ }
564
+ return () => {
565
+ };
566
+ }
567
+ this.listeners.add(listener);
568
+ return () => {
569
+ this.listeners.delete(listener);
570
+ };
571
+ }
572
+ get listenerCount() {
573
+ return this.listeners.size;
574
+ }
575
+ };
576
+
577
+ // src/store.ts
578
+ import { createHash } from "node:crypto";
579
+ import * as fsp from "node:fs/promises";
580
+ import * as path from "node:path";
581
+ import {
582
+ atomicWrite,
583
+ ensureDir,
584
+ resolveWstackPaths,
585
+ ulid as ulid2,
586
+ withFileLock
587
+ } from "@wrongstack/core/utils";
588
+ var INDEX_PATH = "_index.json";
589
+ var IDEMPOTENCY_PATH = "_idempotency.json";
590
+ function hashIdempotencyKey(key) {
591
+ return createHash("sha256").update(key, "utf8").digest("hex");
592
+ }
593
+ var RequirementIntakeStore = class {
594
+ baseDir;
595
+ indexPath;
596
+ idempotencyPath;
597
+ maxIdempotencyEntries;
598
+ lockTimeoutMs;
599
+ constructor(options) {
600
+ this.baseDir = options.baseDir ?? resolveWstackPaths({ projectRoot: process.cwd() }).projectRequirementIntakes;
601
+ this.indexPath = path.join(this.baseDir, INDEX_PATH);
602
+ this.idempotencyPath = path.join(this.baseDir, IDEMPOTENCY_PATH);
603
+ this.maxIdempotencyEntries = options.maxIdempotencyEntries ?? 1e4;
604
+ this.lockTimeoutMs = options.lockTimeoutMs ?? 15e3;
605
+ }
606
+ get directory() {
607
+ return this.baseDir;
608
+ }
609
+ recordPath(id) {
610
+ return path.join(this.baseDir, `${id}.json`);
611
+ }
612
+ // -------------------------------------------------------------------------
613
+ // Reads (lock-free — atomic writes make a torn read impossible)
614
+ // -------------------------------------------------------------------------
615
+ async load(id) {
616
+ try {
617
+ const raw = await fsp.readFile(this.recordPath(id), "utf8");
618
+ return JSON.parse(raw);
619
+ } catch {
620
+ return null;
621
+ }
622
+ }
623
+ async exists(id) {
624
+ try {
625
+ await fsp.access(this.recordPath(id));
626
+ return true;
627
+ } catch {
628
+ return false;
629
+ }
630
+ }
631
+ /** List full records for a project, newest-updated first. */
632
+ async list(projectId, filter) {
633
+ const entries = await this.listIndex(projectId, filter);
634
+ const records = await Promise.all(entries.map((entry) => this.load(entry.id)));
635
+ return records.filter((record) => record !== null);
636
+ }
637
+ /** Cheap listing via the index — no record file reads. */
638
+ async listIndex(projectId, filter) {
639
+ const index = await this.readIndex();
640
+ const statuses = filter?.statuses ? new Set(filter.statuses) : void 0;
641
+ return index.entries.filter((entry) => projectId === void 0 || entry.projectId === projectId).filter((entry) => statuses === void 0 || statuses.has(entry.status)).sort((a, b) => b.updatedAt - a.updatedAt);
642
+ }
643
+ async findByIdempotencyKey(key) {
644
+ const map = await this.readIdempotency();
645
+ const entry = map.entries[hashIdempotencyKey(key)];
646
+ if (!entry) return null;
647
+ return this.load(entry.intakeId);
648
+ }
649
+ // -------------------------------------------------------------------------
650
+ // Writes (serialized per file)
651
+ // -------------------------------------------------------------------------
652
+ /**
653
+ * Persist a new record. When `idempotencyKey` is given, a second create
654
+ * with the same key returns the existing record instead of duplicating it.
655
+ */
656
+ async create(record, idempotencyKey) {
657
+ await ensureDir(this.baseDir);
658
+ if (idempotencyKey !== void 0 && idempotencyKey.trim().length > 0) {
659
+ const key = idempotencyKey.trim();
660
+ return withFileLock(
661
+ this.idempotencyPath,
662
+ async () => {
663
+ const existing = await this.findByIdempotencyKey(key);
664
+ if (existing) {
665
+ return { record: existing, created: false, idempotent: true };
666
+ }
667
+ const map = await this.readIdempotency();
668
+ map.entries[hashIdempotencyKey(key)] = {
669
+ intakeId: record.id,
670
+ createdAt: record.createdAt
671
+ };
672
+ await this.pruneIdempotency(map);
673
+ await atomicWrite(this.idempotencyPath, JSON.stringify(map, null, 2), { mode: 384 });
674
+ const toWrite = { ...record, idempotencyKey: key };
675
+ await this.writeRecord(toWrite);
676
+ await this.updateIndexFor(toWrite);
677
+ return { record: toWrite, created: true, idempotent: false };
678
+ },
679
+ { timeoutMs: this.lockTimeoutMs }
680
+ );
681
+ }
682
+ await this.writeRecord(record);
683
+ await this.updateIndexFor(record);
684
+ return { record, created: true, idempotent: false };
685
+ }
686
+ /**
687
+ * Read-modify-write with optimistic concurrency. `mutate` receives a
688
+ * mutable copy; the store bumps `version`, refreshes `updatedAt`, appends
689
+ * the history entry, and persists atomically under the record lock.
690
+ */
691
+ async update(id, options, mutate) {
692
+ return withFileLock(
693
+ this.recordPath(id),
694
+ async () => {
695
+ const current = await this.load(id);
696
+ if (!current) throw new IntakeNotFoundError(id);
697
+ if (options.expectedVersion !== void 0 && current.version !== options.expectedVersion) {
698
+ throw new IntakeConflictError(id, options.expectedVersion, current.version);
699
+ }
700
+ const next = structuredClone(current);
701
+ await mutate(next);
702
+ next.version = current.version + 1;
703
+ next.updatedAt = Date.now();
704
+ this.appendHistory(next, options);
705
+ await this.writeRecord(next);
706
+ await this.updateIndexFor(next);
707
+ return next;
708
+ },
709
+ { timeoutMs: this.lockTimeoutMs }
710
+ );
711
+ }
712
+ // -------------------------------------------------------------------------
713
+ // Internals
714
+ // -------------------------------------------------------------------------
715
+ async writeRecord(record) {
716
+ await atomicWrite(this.recordPath(record.id), JSON.stringify(record, null, 2), { mode: 384 });
717
+ }
718
+ appendHistory(record, options) {
719
+ const entry = {
720
+ at: Date.now(),
721
+ actor: options.actorId,
722
+ actorType: options.actorType,
723
+ action: options.action ?? "updated",
724
+ fields: options.fields,
725
+ from: options.from,
726
+ to: options.to
727
+ };
728
+ record.history.push(entry);
729
+ if (record.history.length > MAX_HISTORY_ENTRIES) {
730
+ record.history = record.history.slice(record.history.length - MAX_HISTORY_ENTRIES);
731
+ }
732
+ }
733
+ async readIndex() {
734
+ try {
735
+ const raw = await fsp.readFile(this.indexPath, "utf8");
736
+ const parsed = JSON.parse(raw);
737
+ if (parsed?.version === 1 && Array.isArray(parsed.entries)) return parsed;
738
+ } catch {
739
+ }
740
+ return { version: 1, entries: [] };
741
+ }
742
+ async updateIndexFor(record) {
743
+ return withFileLock(
744
+ this.indexPath,
745
+ async () => {
746
+ const index = await this.readIndex();
747
+ const entry = {
748
+ id: record.id,
749
+ projectId: record.projectId,
750
+ title: record.title,
751
+ status: record.status,
752
+ requestType: record.requestType,
753
+ priority: record.priority,
754
+ requestedBy: record.requestedBy,
755
+ updatedAt: record.updatedAt,
756
+ createdAt: record.createdAt
757
+ };
758
+ const position = index.entries.findIndex((candidate) => candidate.id === record.id);
759
+ if (position >= 0) {
760
+ index.entries[position] = entry;
761
+ } else {
762
+ index.entries.push(entry);
763
+ }
764
+ await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
765
+ },
766
+ { timeoutMs: this.lockTimeoutMs }
767
+ );
768
+ }
769
+ async readIdempotency() {
770
+ try {
771
+ const raw = await fsp.readFile(this.idempotencyPath, "utf8");
772
+ const parsed = JSON.parse(raw);
773
+ if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object")
774
+ return parsed;
775
+ } catch {
776
+ }
777
+ return { version: 1, entries: {} };
778
+ }
779
+ async pruneIdempotency(map) {
780
+ const entries = Object.entries(map.entries);
781
+ if (entries.length <= this.maxIdempotencyEntries) return;
782
+ entries.sort((a, b) => a[1].createdAt - b[1].createdAt);
783
+ const keep = new Set(
784
+ entries.slice(entries.length - this.maxIdempotencyEntries).map(([key]) => key)
785
+ );
786
+ for (const key of Object.keys(map.entries)) {
787
+ if (!keep.has(key)) delete map.entries[key];
788
+ }
789
+ }
790
+ };
791
+ function newIntakeId() {
792
+ return `reqi_${ulid2()}`;
793
+ }
794
+
795
+ // src/suggestions.ts
796
+ import { z as z2 } from "zod";
797
+ import { ulid as ulid3 } from "@wrongstack/core/utils";
798
+ var outputQuestionSchema = z2.object({
799
+ field: z2.string().trim().min(1).max(64),
800
+ question: z2.string().trim().min(1).max(MAX_QUESTION_LENGTH),
801
+ required: z2.boolean().optional()
802
+ });
803
+ var outputStringArraySchema = z2.array(z2.string().trim().min(1).max(MAX_STRING_FIELD_LENGTH)).max(MAX_ARRAY_ITEMS);
804
+ var llmSuggestionOutputSchema = z2.object({
805
+ suggested_title: z2.string().trim().min(1).max(MAX_TITLE_LENGTH).optional(),
806
+ normalized_summary: z2.string().trim().min(1).max(MAX_SUMMARY_LENGTH).optional(),
807
+ suggested_request_type: z2.string().trim().min(1).max(64).optional(),
808
+ suggested_priority: z2.string().trim().min(1).max(32).optional(),
809
+ extracted_constraints: outputStringArraySchema.optional(),
810
+ extracted_target_users: outputStringArraySchema.optional(),
811
+ suggested_outcome: z2.string().trim().min(1).max(MAX_ANSWER_LENGTH).optional(),
812
+ suggested_questions: z2.array(outputQuestionSchema).max(20).optional()
813
+ });
814
+ function validateLlmSuggestionOutput(raw) {
815
+ const parsed = llmSuggestionOutputSchema.safeParse(raw);
816
+ if (!parsed.success) {
817
+ const issues = parsed.error.issues.map((issue) => issue.message).join("; ");
818
+ throw new IntakeSuggestionError(`Malformed LLM suggestion output: ${issues}`);
819
+ }
820
+ const value = parsed.data;
821
+ const requestType = value.suggested_request_type ? normalizeRequestType(value.suggested_request_type) : void 0;
822
+ const priority = value.suggested_priority ? INTAKE_PRIORITIES.includes(value.suggested_priority) ? value.suggested_priority : void 0 : void 0;
823
+ return {
824
+ suggestedTitle: value.suggested_title,
825
+ normalizedSummary: value.normalized_summary,
826
+ suggestedRequestType: requestType,
827
+ suggestedPriority: priority,
828
+ extractedConstraints: value.extracted_constraints ?? [],
829
+ extractedTargetUsers: value.extracted_target_users ?? [],
830
+ suggestedOutcome: value.suggested_outcome,
831
+ suggestedQuestions: value.suggested_questions ?? []
832
+ };
833
+ }
834
+ function proposal(kind, field, value) {
835
+ return {
836
+ id: `${SUGGESTION_ID_PREFIX}${ulid3()}`,
837
+ kind,
838
+ field,
839
+ value,
840
+ status: "pending",
841
+ createdAt: Date.now()
842
+ };
843
+ }
844
+ function toProposals(suggestion) {
845
+ const proposals = [];
846
+ if (suggestion.suggestedTitle !== void 0) {
847
+ proposals.push(proposal("title", "title", suggestion.suggestedTitle));
848
+ }
849
+ if (suggestion.normalizedSummary !== void 0) {
850
+ proposals.push(proposal("summary", "normalized_summary", suggestion.normalizedSummary));
851
+ }
852
+ if (suggestion.suggestedRequestType !== void 0) {
853
+ proposals.push(proposal("request_type", "request_type", suggestion.suggestedRequestType));
854
+ }
855
+ if (suggestion.suggestedPriority !== void 0) {
856
+ proposals.push(proposal("priority", "priority", suggestion.suggestedPriority));
857
+ }
858
+ for (const constraint of suggestion.extractedConstraints) {
859
+ proposals.push(proposal("constraint", "constraints", constraint));
860
+ }
861
+ for (const targetUser of suggestion.extractedTargetUsers) {
862
+ proposals.push(proposal("target_user", "target_users", targetUser));
863
+ }
864
+ if (suggestion.suggestedOutcome !== void 0) {
865
+ proposals.push(proposal("outcome", "expected_outcome", suggestion.suggestedOutcome));
866
+ }
867
+ for (const question of suggestion.suggestedQuestions) {
868
+ proposals.push(proposal("question", question.field, question));
869
+ }
870
+ return proposals;
871
+ }
872
+ function assertSuggestionString(value, label, max) {
873
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
874
+ throw new IntakeValidationError([
875
+ { field: `suggestion.${label}`, message: `invalid ${label} suggestion value` }
876
+ ]);
877
+ }
878
+ return value;
879
+ }
880
+
881
+ // src/service.ts
882
+ import { ulid as ulid4 } from "@wrongstack/core/utils";
883
+ var ANSWER_FIELD_MAPPING = {
884
+ business_goal: {
885
+ set: (record, value) => {
886
+ record.businessGoal = value;
887
+ }
888
+ },
889
+ expected_outcome: {
890
+ set: (record, value) => {
891
+ record.expectedOutcome = value;
892
+ }
893
+ },
894
+ scope_notes: {
895
+ set: (record, value) => {
896
+ record.scopeNotes = value;
897
+ }
898
+ },
899
+ description_scope: {
900
+ set: (record, value) => {
901
+ record.scopeNotes = value;
902
+ }
903
+ },
904
+ target_users: {
905
+ set: (record, value) => {
906
+ appendItems(record.targetUsers, value);
907
+ }
908
+ },
909
+ constraints: {
910
+ set: (record, value) => {
911
+ appendItems(record.constraints, value);
912
+ }
913
+ },
914
+ provided_context: {
915
+ set: (record, value) => {
916
+ appendItems(record.providedContext, value);
917
+ }
918
+ },
919
+ project_component: {
920
+ set: (record, value) => {
921
+ appendItems(record.providedContext, value);
922
+ }
923
+ },
924
+ priority: {
925
+ set: (record, value) => {
926
+ const trimmed = value.trim().toLowerCase();
927
+ if (INTAKE_PRIORITIES.includes(trimmed)) {
928
+ record.priority = trimmed;
929
+ record.fieldSources.priority = "user";
930
+ }
931
+ }
932
+ }
933
+ };
934
+ function appendItems(target, value) {
935
+ const items = value.split(/\r?\n/).map((item) => item.trim()).filter((item) => item.length > 0);
936
+ const remaining = Math.max(0, MAX_ARRAY_ITEMS - target.length);
937
+ target.push(...items.slice(0, remaining));
938
+ }
939
+ var RequirementIntakeService = class {
940
+ store;
941
+ authorizer;
942
+ generator;
943
+ emitter;
944
+ logger;
945
+ metrics;
946
+ catalog;
947
+ constructor(options) {
948
+ this.store = options.store;
949
+ this.authorizer = options.authorizer;
950
+ this.generator = options.generator;
951
+ this.emitter = options.emitter ?? new IntakeEventEmitter();
952
+ this.logger = options.logger ?? new NoopIntakeLogger();
953
+ this.metrics = options.metrics ?? new InMemoryIntakeMetrics();
954
+ this.catalog = options.questions ?? DEFAULT_INTAKE_QUESTIONS;
955
+ }
956
+ /** Subscribe to domain events. Returns a disposer. */
957
+ subscribe(listener) {
958
+ return this.emitter.subscribe(listener);
959
+ }
960
+ // -------------------------------------------------------------------------
961
+ // Creation
962
+ // -------------------------------------------------------------------------
963
+ async createIntake(input, ctx) {
964
+ const validated = this.guardValidation(() => validateCreateInput(input));
965
+ if (validated.projectId !== ctx.projectId) {
966
+ throw new IntakeAuthorizationError("create", ctx.id, validated.projectId);
967
+ }
968
+ await this.authorize("create", ctx);
969
+ if (validated.idempotencyKey !== void 0 && validated.idempotencyKey.trim().length > 0) {
970
+ const existing = await this.store.findByIdempotencyKey(validated.idempotencyKey);
971
+ if (existing) {
972
+ if (existing.projectId !== ctx.projectId) {
973
+ throw new IntakeValidationError([
974
+ {
975
+ field: "idempotencyKey",
976
+ message: "idempotency key already used for a different project"
977
+ }
978
+ ]);
979
+ }
980
+ this.metrics.increment("intake.duplicate_create");
981
+ this.logger.info("intake", "intake.duplicate_create", {
982
+ intakeId: existing.id,
983
+ projectId: existing.projectId,
984
+ actorId: ctx.id
985
+ });
986
+ return { record: existing, created: false, idempotent: true };
987
+ }
988
+ }
989
+ const now = Date.now();
990
+ const record = this.buildNewRecord(validated, ctx, now);
991
+ const result = await this.store.create(record, validated.idempotencyKey?.trim());
992
+ this.logger.info("intake", "intake.created", {
993
+ intakeId: result.record.id,
994
+ projectId: result.record.projectId,
995
+ actorId: ctx.id
996
+ });
997
+ this.metrics.increment("intake.created");
998
+ this.emit("RequirementIntakeCreated", {
999
+ intakeId: result.record.id,
1000
+ projectId: result.record.projectId,
1001
+ actorId: ctx.id,
1002
+ actorType: ctx.type,
1003
+ status: result.record.status
1004
+ });
1005
+ return result;
1006
+ }
1007
+ // -------------------------------------------------------------------------
1008
+ // Reads
1009
+ // -------------------------------------------------------------------------
1010
+ async getIntake(id, ctx) {
1011
+ const record = await this.store.load(id);
1012
+ if (!record) return null;
1013
+ await this.authorize("read", ctx, record);
1014
+ return record;
1015
+ }
1016
+ async listIntakes(projectId, ctx, filter) {
1017
+ if (projectId !== ctx.projectId) {
1018
+ throw new IntakeAuthorizationError("list", ctx.id, projectId);
1019
+ }
1020
+ await this.authorize("list", ctx);
1021
+ return this.store.list(projectId, filter);
1022
+ }
1023
+ async pendingQuestions(id, ctx) {
1024
+ const record = await this.requireRecord(id, ctx, "read");
1025
+ return pendingQuestions(record);
1026
+ }
1027
+ // -------------------------------------------------------------------------
1028
+ // Draft editing
1029
+ // -------------------------------------------------------------------------
1030
+ async updateIntake(id, patch, ctx, expectedVersion) {
1031
+ const record = await this.requireRecord(id, ctx, "update");
1032
+ this.assertMutable(record, "updateIntake");
1033
+ const validated = this.guardValidation(() => validateUpdateInput(patch));
1034
+ const changedKeys = Object.keys(validated);
1035
+ if (changedKeys.length === 0) return record;
1036
+ if (validated.title !== void 0 && validated.title.trim().length === 0) {
1037
+ throw new IntakeValidationError([{ field: "title", message: "title must not be blank" }]);
1038
+ }
1039
+ return this.store.update(id, this.updateMeta(ctx, "updated", changedKeys, expectedVersion), (next) => {
1040
+ if (validated.title !== void 0) {
1041
+ next.title = validated.title.trim();
1042
+ next.fieldSources.title = "user";
1043
+ }
1044
+ if (validated.requestType !== void 0) {
1045
+ next.requestType = normalizeRequestType(validated.requestType);
1046
+ next.fieldSources.request_type = "user";
1047
+ }
1048
+ if (validated.priority !== void 0) {
1049
+ next.priority = validated.priority;
1050
+ next.fieldSources.priority = "user";
1051
+ }
1052
+ applyOptionalString(next, "businessGoal", validated.businessGoal);
1053
+ applyOptionalString(next, "expectedOutcome", validated.expectedOutcome);
1054
+ applyOptionalString(next, "scopeNotes", validated.scopeNotes);
1055
+ if (validated.targetUsers !== void 0) next.targetUsers = [...validated.targetUsers];
1056
+ if (validated.constraints !== void 0) next.constraints = [...validated.constraints];
1057
+ if (validated.providedContext !== void 0)
1058
+ next.providedContext = [...validated.providedContext];
1059
+ if (validated.metadata !== void 0) next.metadata = validated.metadata;
1060
+ markUserSources(next, changedKeys);
1061
+ }).then((updated) => {
1062
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1063
+ return updated;
1064
+ });
1065
+ }
1066
+ async addAnswer(id, input, ctx, expectedVersion) {
1067
+ const record = await this.requireRecord(id, ctx, "answer");
1068
+ this.assertMutable(record, "addAnswer");
1069
+ const validated = this.guardValidation(() => validateAnswerInput(input));
1070
+ this.assertAnswerField(validated.field);
1071
+ return this.store.update(
1072
+ id,
1073
+ this.updateMeta(ctx, "answer_added", [validated.field], expectedVersion),
1074
+ (next) => {
1075
+ const question = next.questions.find((candidate) => candidate.field === validated.field);
1076
+ const answer = {
1077
+ id: `${ANSWER_ID_PREFIX}${ulid4()}`,
1078
+ field: validated.field,
1079
+ question: validated.question ?? question?.question ?? validated.field,
1080
+ answer: validated.answer,
1081
+ source: "user",
1082
+ answeredBy: ctx.id,
1083
+ answeredAt: Date.now()
1084
+ };
1085
+ next.answers.push(answer);
1086
+ if (question && question.status === "unanswered") {
1087
+ question.status = "answered";
1088
+ question.answer = validated.answer;
1089
+ }
1090
+ ANSWER_FIELD_MAPPING[validated.field]?.set(next, validated.answer);
1091
+ if (INTAKE_FIELDS.includes(validated.field)) {
1092
+ next.fieldSources[validated.field] = "user";
1093
+ }
1094
+ }
1095
+ ).then((updated) => {
1096
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1097
+ return updated;
1098
+ });
1099
+ }
1100
+ async updateAnswer(id, answerId, patch, ctx, expectedVersion) {
1101
+ const record = await this.requireRecord(id, ctx, "answer");
1102
+ this.assertMutable(record, "updateAnswer");
1103
+ const validated = this.guardValidation(
1104
+ () => validateAnswerInput({
1105
+ field: record.answers.find((a) => a.id === answerId)?.field ?? "unknown",
1106
+ answer: patch.answer
1107
+ })
1108
+ );
1109
+ return this.store.update(id, this.updateMeta(ctx, "answer_updated", [answerId], expectedVersion), (next) => {
1110
+ const answer = next.answers.find((candidate) => candidate.id === answerId);
1111
+ if (!answer) {
1112
+ throw new IntakeValidationError([
1113
+ { field: "answerId", message: `answer not found: ${answerId}` }
1114
+ ]);
1115
+ }
1116
+ answer.answer = validated.answer;
1117
+ answer.answeredAt = Date.now();
1118
+ const question = next.questions.find((candidate) => candidate.field === answer.field);
1119
+ if (question) {
1120
+ question.answer = validated.answer;
1121
+ question.status = "answered";
1122
+ }
1123
+ }).then((updated) => {
1124
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1125
+ return updated;
1126
+ });
1127
+ }
1128
+ async attachResource(id, input, ctx, expectedVersion) {
1129
+ const record = await this.requireRecord(id, ctx, "attach");
1130
+ this.assertMutable(record, "attachResource");
1131
+ const validated = this.guardValidation(() => validateAttachResourceInput(input));
1132
+ const now = Date.now();
1133
+ if (validated.attachment !== void 0) {
1134
+ if (record.attachments.length >= MAX_ATTACHMENTS) {
1135
+ throw new IntakeValidationError([
1136
+ { field: "attachments", message: `maximum of ${MAX_ATTACHMENTS} attachments reached` }
1137
+ ]);
1138
+ }
1139
+ return this.store.update(
1140
+ id,
1141
+ this.updateMeta(ctx, "attachment_added", ["attachments"], expectedVersion),
1142
+ (next) => {
1143
+ const attachment = {
1144
+ id: `${ATTACHMENT_ID_PREFIX}${ulid4()}`,
1145
+ name: validated.attachment.name,
1146
+ kind: validated.attachment.kind,
1147
+ path: validated.attachment?.path,
1148
+ url: validated.attachment?.url,
1149
+ sizeBytes: validated.attachment?.sizeBytes,
1150
+ mimeType: validated.attachment?.mimeType,
1151
+ source: "user",
1152
+ addedBy: ctx.id,
1153
+ addedAt: now
1154
+ };
1155
+ next.attachments.push(attachment);
1156
+ next.fieldSources.attachments = "user";
1157
+ markQuestionAnswered(next, "attachments", attachment.name);
1158
+ }
1159
+ ).then((updated) => {
1160
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1161
+ return updated;
1162
+ });
1163
+ }
1164
+ if (record.relatedResources.length >= MAX_RELATED_RESOURCES) {
1165
+ throw new IntakeValidationError([
1166
+ {
1167
+ field: "relatedResources",
1168
+ message: `maximum of ${MAX_RELATED_RESOURCES} related resources reached`
1169
+ }
1170
+ ]);
1171
+ }
1172
+ return this.store.update(
1173
+ id,
1174
+ this.updateMeta(ctx, "related_resource_added", ["related_resources"], expectedVersion),
1175
+ (next) => {
1176
+ const resource = {
1177
+ id: `${RELATED_RESOURCE_ID_PREFIX}${ulid4()}`,
1178
+ kind: validated.relatedResource.kind,
1179
+ reference: validated.relatedResource.reference,
1180
+ title: validated.relatedResource?.title,
1181
+ source: "user",
1182
+ addedBy: ctx.id,
1183
+ addedAt: now
1184
+ };
1185
+ next.relatedResources.push(resource);
1186
+ next.fieldSources.related_resources = "user";
1187
+ markQuestionAnswered(next, "related_resources", resource.reference);
1188
+ }
1189
+ ).then((updated) => {
1190
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1191
+ return updated;
1192
+ });
1193
+ }
1194
+ // -------------------------------------------------------------------------
1195
+ // LLM suggestions (always proposals)
1196
+ // -------------------------------------------------------------------------
1197
+ async generateSuggestions(id, ctx, focus) {
1198
+ const record = await this.requireRecord(id, ctx, "suggest");
1199
+ this.assertMutable(record, "generateSuggestions");
1200
+ if (!this.generator) {
1201
+ throw new IntakeSuggestionError("No LLM suggestion generator is configured on this service");
1202
+ }
1203
+ this.metrics.increment("intake.suggestions.requested");
1204
+ let output;
1205
+ try {
1206
+ output = await this.generator.generate({ record, focus });
1207
+ } catch (error) {
1208
+ this.metrics.increment("intake.suggestions.failed");
1209
+ this.logger.error("intake", "intake.suggestions.failed", {
1210
+ intakeId: record.id,
1211
+ projectId: record.projectId,
1212
+ actorId: ctx.id
1213
+ });
1214
+ throw new IntakeSuggestionError("LLM suggestion generation failed", { cause: error });
1215
+ }
1216
+ let proposals;
1217
+ try {
1218
+ proposals = toProposals(validateLlmSuggestionOutput(output));
1219
+ } catch (error) {
1220
+ this.metrics.increment("intake.suggestions.failed");
1221
+ throw error instanceof IntakeSuggestionError ? error : new IntakeSuggestionError("LLM suggestion output could not be validated", {
1222
+ cause: error
1223
+ });
1224
+ }
1225
+ if (proposals.length === 0) {
1226
+ this.metrics.increment("intake.suggestions.failed");
1227
+ throw new IntakeSuggestionError("LLM suggestion output contained no usable proposals");
1228
+ }
1229
+ const hadQuestions = proposals.some((proposal2) => proposal2.kind === "question");
1230
+ const previousStatus = record.status;
1231
+ const nextStatus = previousStatus === "draft" ? "collecting_information" : previousStatus;
1232
+ const updated = await this.store.update(
1233
+ id,
1234
+ {
1235
+ actorId: ctx.id,
1236
+ actorType: ctx.type,
1237
+ action: hadQuestions ? "information_requested" : "suggestions_added",
1238
+ from: previousStatus === nextStatus ? void 0 : previousStatus,
1239
+ to: previousStatus === nextStatus ? void 0 : nextStatus
1240
+ },
1241
+ (next) => {
1242
+ next.llmSuggestions.push(...proposals);
1243
+ if (next.llmSuggestions.length > MAX_SUGGESTIONS) {
1244
+ next.llmSuggestions = next.llmSuggestions.slice(
1245
+ next.llmSuggestions.length - MAX_SUGGESTIONS
1246
+ );
1247
+ }
1248
+ if (previousStatus === "draft" && nextStatus === "collecting_information") {
1249
+ next.status = nextStatus;
1250
+ }
1251
+ }
1252
+ );
1253
+ this.metrics.increment("intake.suggestions.succeeded");
1254
+ this.logger.info("intake", "intake.suggestions.succeeded", {
1255
+ intakeId: updated.id,
1256
+ projectId: updated.projectId,
1257
+ actorId: ctx.id,
1258
+ count: proposals.length
1259
+ });
1260
+ this.emit(hadQuestions ? "RequirementIntakeInformationRequested" : "RequirementIntakeUpdated", {
1261
+ intakeId: updated.id,
1262
+ projectId: updated.projectId,
1263
+ actorId: ctx.id,
1264
+ actorType: ctx.type,
1265
+ previousStatus,
1266
+ status: updated.status
1267
+ });
1268
+ return proposals;
1269
+ }
1270
+ async acceptSuggestion(id, proposalId, ctx, expectedVersion) {
1271
+ const record = await this.requireRecord(id, ctx, "accept_suggestion");
1272
+ this.assertMutable(record, "acceptSuggestion");
1273
+ const proposal2 = this.findSuggestion(record, proposalId);
1274
+ if (proposal2.status !== "pending") {
1275
+ throw new IntakeValidationError([
1276
+ { field: "suggestionId", message: `suggestion is already ${proposal2.status}` }
1277
+ ]);
1278
+ }
1279
+ return this.store.update(
1280
+ id,
1281
+ this.updateMeta(ctx, "suggestion_accepted", [proposal2.kind], expectedVersion),
1282
+ (next) => {
1283
+ const target = next.llmSuggestions.find((candidate) => candidate.id === proposalId);
1284
+ if (!target) {
1285
+ throw new IntakeValidationError([
1286
+ { field: "suggestionId", message: `suggestion not found: ${proposalId}` }
1287
+ ]);
1288
+ }
1289
+ this.applyProposal(next, target);
1290
+ target.status = "accepted";
1291
+ target.resolvedAt = Date.now();
1292
+ }
1293
+ ).then((updated) => {
1294
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1295
+ return updated;
1296
+ });
1297
+ }
1298
+ async rejectSuggestion(id, proposalId, ctx, expectedVersion) {
1299
+ const record = await this.requireRecord(id, ctx, "reject_suggestion");
1300
+ this.assertMutable(record, "rejectSuggestion");
1301
+ this.findSuggestion(record, proposalId);
1302
+ return this.store.update(
1303
+ id,
1304
+ this.updateMeta(ctx, "suggestion_rejected", [proposalId], expectedVersion),
1305
+ (next) => {
1306
+ const target = next.llmSuggestions.find((candidate) => candidate.id === proposalId);
1307
+ if (!target) {
1308
+ throw new IntakeValidationError([
1309
+ { field: "suggestionId", message: `suggestion not found: ${proposalId}` }
1310
+ ]);
1311
+ }
1312
+ if (target.status !== "pending") {
1313
+ throw new IntakeValidationError([
1314
+ { field: "suggestionId", message: `suggestion is already ${target.status}` }
1315
+ ]);
1316
+ }
1317
+ target.status = "rejected";
1318
+ target.resolvedAt = Date.now();
1319
+ }
1320
+ ).then((updated) => {
1321
+ this.afterMutation(updated, ctx, "RequirementIntakeUpdated");
1322
+ return updated;
1323
+ });
1324
+ }
1325
+ // -------------------------------------------------------------------------
1326
+ // Lifecycle
1327
+ // -------------------------------------------------------------------------
1328
+ async submitIntake(id, ctx, expectedVersion) {
1329
+ const record = await this.requireRecord(id, ctx, "submit");
1330
+ if (record.status === "submitted") {
1331
+ this.metrics.increment("intake.duplicate_submit");
1332
+ return { record, idempotent: true };
1333
+ }
1334
+ assertTransition(record.status, "submitted");
1335
+ this.assertSubmitReady(record);
1336
+ const now = Date.now();
1337
+ try {
1338
+ const updated = await this.store.update(
1339
+ id,
1340
+ {
1341
+ actorId: ctx.id,
1342
+ actorType: ctx.type,
1343
+ action: "submitted",
1344
+ from: record.status,
1345
+ to: "submitted",
1346
+ expectedVersion: expectedVersion ?? record.version
1347
+ },
1348
+ (next) => {
1349
+ next.status = "submitted";
1350
+ next.submittedAt = now;
1351
+ next.submittedBy = ctx.id;
1352
+ next.submittedByType = ctx.type;
1353
+ }
1354
+ );
1355
+ this.metrics.increment("intake.submitted");
1356
+ this.metrics.recordDuration("intake.time_to_submit", now - updated.createdAt);
1357
+ this.logger.info("intake", "intake.submitted", {
1358
+ intakeId: updated.id,
1359
+ projectId: updated.projectId,
1360
+ actorId: ctx.id
1361
+ });
1362
+ this.emit("RequirementIntakeSubmitted", {
1363
+ intakeId: updated.id,
1364
+ projectId: updated.projectId,
1365
+ actorId: ctx.id,
1366
+ actorType: ctx.type,
1367
+ previousStatus: record.status,
1368
+ status: "submitted"
1369
+ });
1370
+ return { record: updated, idempotent: false };
1371
+ } catch (error) {
1372
+ if (error instanceof IntakeConflictError) {
1373
+ const latest = await this.store.load(id);
1374
+ if (latest && latest.status === "submitted") {
1375
+ this.metrics.increment("intake.duplicate_submit");
1376
+ return { record: latest, idempotent: true };
1377
+ }
1378
+ }
1379
+ throw error;
1380
+ }
1381
+ }
1382
+ async cancelIntake(id, ctx, reason, expectedVersion) {
1383
+ const record = await this.requireRecord(id, ctx, "cancel");
1384
+ assertTransition(record.status, "cancelled");
1385
+ const updated = await this.store.update(
1386
+ id,
1387
+ {
1388
+ actorId: ctx.id,
1389
+ actorType: ctx.type,
1390
+ action: "cancelled",
1391
+ from: record.status,
1392
+ to: "cancelled",
1393
+ expectedVersion
1394
+ },
1395
+ (next) => {
1396
+ next.status = "cancelled";
1397
+ next.cancelledAt = Date.now();
1398
+ next.cancelledReason = reason?.trim() || void 0;
1399
+ }
1400
+ );
1401
+ this.metrics.increment("intake.cancelled");
1402
+ this.logger.info("intake", "intake.cancelled", {
1403
+ intakeId: updated.id,
1404
+ projectId: updated.projectId,
1405
+ actorId: ctx.id
1406
+ });
1407
+ this.emit("RequirementIntakeCancelled", {
1408
+ intakeId: updated.id,
1409
+ projectId: updated.projectId,
1410
+ actorId: ctx.id,
1411
+ actorType: ctx.type,
1412
+ previousStatus: record.status,
1413
+ status: "cancelled"
1414
+ });
1415
+ return updated;
1416
+ }
1417
+ async archiveIntake(id, ctx, expectedVersion) {
1418
+ const record = await this.requireRecord(id, ctx, "archive");
1419
+ assertTransition(record.status, "archived");
1420
+ const updated = await this.store.update(
1421
+ id,
1422
+ {
1423
+ actorId: ctx.id,
1424
+ actorType: ctx.type,
1425
+ action: "archived",
1426
+ from: record.status,
1427
+ to: "archived",
1428
+ expectedVersion
1429
+ },
1430
+ (next) => {
1431
+ next.status = "archived";
1432
+ next.archivedAt = Date.now();
1433
+ }
1434
+ );
1435
+ this.metrics.increment("intake.archived");
1436
+ this.logger.info("intake", "intake.archived", {
1437
+ intakeId: updated.id,
1438
+ projectId: updated.projectId,
1439
+ actorId: ctx.id
1440
+ });
1441
+ this.emit("RequirementIntakeArchived", {
1442
+ intakeId: updated.id,
1443
+ projectId: updated.projectId,
1444
+ actorId: ctx.id,
1445
+ actorType: ctx.type,
1446
+ previousStatus: record.status,
1447
+ status: "archived"
1448
+ });
1449
+ return updated;
1450
+ }
1451
+ // -------------------------------------------------------------------------
1452
+ // Internals
1453
+ // -------------------------------------------------------------------------
1454
+ buildNewRecord(input, ctx, now) {
1455
+ const titleProvided = input.title !== void 0 && input.title.trim().length > 0;
1456
+ const title = titleProvided ? input.title.trim() : deterministicTitle(input.originalRequest);
1457
+ const requestType = normalizeRequestType(input.requestType);
1458
+ const idempotencyKey = input.idempotencyKey?.trim();
1459
+ const attachments = (input.attachments ?? []).map((attachment) => ({
1460
+ id: `${ATTACHMENT_ID_PREFIX}${ulid4()}`,
1461
+ name: attachment.name,
1462
+ kind: attachment.kind,
1463
+ path: attachment.path,
1464
+ url: attachment.url,
1465
+ sizeBytes: attachment.sizeBytes,
1466
+ mimeType: attachment.mimeType,
1467
+ source: "user",
1468
+ addedBy: ctx.id,
1469
+ addedAt: now
1470
+ }));
1471
+ const relatedResources = (input.relatedResources ?? []).map((resource) => ({
1472
+ id: `${RELATED_RESOURCE_ID_PREFIX}${ulid4()}`,
1473
+ kind: resource.kind,
1474
+ reference: resource.reference,
1475
+ title: resource.title,
1476
+ source: "user",
1477
+ addedBy: ctx.id,
1478
+ addedAt: now
1479
+ }));
1480
+ return {
1481
+ id: newIntakeId(),
1482
+ projectId: input.projectId,
1483
+ title,
1484
+ originalRequest: input.originalRequest,
1485
+ normalizedSummary: deterministicSummary(input.originalRequest),
1486
+ requestType,
1487
+ status: "draft",
1488
+ priority: input.priority ?? "unspecified",
1489
+ requestedBy: input.requestedBy,
1490
+ ...input.businessGoal !== void 0 ? { businessGoal: input.businessGoal } : {},
1491
+ targetUsers: [...input.targetUsers ?? []],
1492
+ ...input.expectedOutcome !== void 0 ? { expectedOutcome: input.expectedOutcome } : {},
1493
+ ...input.scopeNotes !== void 0 ? { scopeNotes: input.scopeNotes } : {},
1494
+ constraints: [...input.constraints ?? []],
1495
+ providedContext: [...input.providedContext ?? []],
1496
+ attachments,
1497
+ relatedResources,
1498
+ answers: [],
1499
+ questions: buildInitialQuestions(input, this.catalog),
1500
+ llmSuggestions: [],
1501
+ metadata: { ...input.metadata ?? {} },
1502
+ fieldSources: {
1503
+ ...titleProvided ? { title: "user" } : { title: "deterministic" },
1504
+ normalized_summary: "deterministic",
1505
+ request_type: input.requestType !== void 0 ? "user" : "deterministic",
1506
+ priority: input.priority !== void 0 ? "user" : "deterministic",
1507
+ ...input.businessGoal !== void 0 ? { business_goal: "user" } : {},
1508
+ ...input.targetUsers !== void 0 ? { target_users: "user" } : {},
1509
+ ...input.expectedOutcome !== void 0 ? { expected_outcome: "user" } : {},
1510
+ ...input.scopeNotes !== void 0 ? { scope_notes: "user" } : {},
1511
+ ...input.constraints !== void 0 ? { constraints: "user" } : {},
1512
+ ...input.providedContext !== void 0 ? { provided_context: "user" } : {},
1513
+ ...attachments.length > 0 ? { attachments: "user" } : {},
1514
+ ...relatedResources.length > 0 ? { related_resources: "user" } : {}
1515
+ },
1516
+ ...idempotencyKey !== void 0 && idempotencyKey.length > 0 ? { idempotencyKey } : {},
1517
+ version: 1,
1518
+ history: [{ at: now, actor: ctx.id, actorType: ctx.type, action: "created" }],
1519
+ createdAt: now,
1520
+ updatedAt: now
1521
+ };
1522
+ }
1523
+ async requireRecord(id, ctx, operation) {
1524
+ const record = await this.store.load(id);
1525
+ if (!record) throw new IntakeNotFoundError(id);
1526
+ await this.authorize(operation, ctx, record);
1527
+ return record;
1528
+ }
1529
+ async authorize(operation, ctx, record) {
1530
+ const allowed = await this.authorizer.isAllowed(operation, ctx, record);
1531
+ if (!allowed) {
1532
+ this.metrics.increment("intake.unauthorized_attempt");
1533
+ throw new IntakeAuthorizationError(operation, ctx.id, ctx.projectId);
1534
+ }
1535
+ }
1536
+ assertMutable(record, action) {
1537
+ if (!isMutableStatus(record.status)) {
1538
+ throw new IntakeStatusLockedError(record.id, record.status, action);
1539
+ }
1540
+ }
1541
+ assertAnswerField(field) {
1542
+ const catalogFields = new Set(this.catalog.map((template) => template.field));
1543
+ if (!catalogFields.has(field) && !INTAKE_FIELDS.includes(field)) {
1544
+ throw new IntakeValidationError([
1545
+ { field: "field", message: `unknown intake field: ${field}` }
1546
+ ]);
1547
+ }
1548
+ }
1549
+ assertSubmitReady(record) {
1550
+ const issues = [];
1551
+ if (record.originalRequest.trim().length === 0) {
1552
+ issues.push({ field: "originalRequest", message: "original request must not be empty" });
1553
+ }
1554
+ if (record.title.trim().length === 0) {
1555
+ issues.push({ field: "title", message: "title must not be empty" });
1556
+ }
1557
+ if (record.requestedBy.trim().length === 0) {
1558
+ issues.push({ field: "requestedBy", message: "requester must not be empty" });
1559
+ }
1560
+ if (record.projectId.trim().length === 0) {
1561
+ issues.push({ field: "projectId", message: "project must not be empty" });
1562
+ }
1563
+ if (issues.length > 0) {
1564
+ this.metrics.increment("intake.validation_failure");
1565
+ throw new IntakeValidationError(issues, "Requirement intake is not ready for submission");
1566
+ }
1567
+ }
1568
+ findSuggestion(record, proposalId) {
1569
+ const proposal2 = record.llmSuggestions.find((candidate) => candidate.id === proposalId);
1570
+ if (!proposal2) {
1571
+ throw new IntakeValidationError([
1572
+ { field: "suggestionId", message: `suggestion not found: ${proposalId}` }
1573
+ ]);
1574
+ }
1575
+ return proposal2;
1576
+ }
1577
+ applyProposal(record, proposal2) {
1578
+ switch (proposal2.kind) {
1579
+ case "title": {
1580
+ const value = assertSuggestionString(proposal2.value, "title", MAX_TITLE_LENGTH);
1581
+ record.title = value;
1582
+ record.fieldSources.title = "llm";
1583
+ break;
1584
+ }
1585
+ case "summary": {
1586
+ const value = assertSuggestionString(
1587
+ proposal2.value,
1588
+ "normalized_summary",
1589
+ MAX_SUMMARY_LENGTH
1590
+ );
1591
+ record.normalizedSummary = value;
1592
+ record.fieldSources.normalized_summary = "llm";
1593
+ break;
1594
+ }
1595
+ case "request_type": {
1596
+ const value = normalizeRequestType(proposal2.value);
1597
+ record.requestType = value;
1598
+ record.fieldSources.request_type = "llm";
1599
+ break;
1600
+ }
1601
+ case "priority": {
1602
+ const value = String(proposal2.value).trim().toLowerCase();
1603
+ if (INTAKE_PRIORITIES.includes(value)) {
1604
+ record.priority = value;
1605
+ record.fieldSources.priority = "llm";
1606
+ }
1607
+ break;
1608
+ }
1609
+ case "constraint": {
1610
+ const value = assertSuggestionString(proposal2.value, "constraint", MAX_STRING_FIELD_LENGTH);
1611
+ appendItems(record.constraints, value);
1612
+ record.fieldSources.constraints = "llm";
1613
+ break;
1614
+ }
1615
+ case "target_user": {
1616
+ const value = assertSuggestionString(
1617
+ proposal2.value,
1618
+ "target_user",
1619
+ MAX_STRING_FIELD_LENGTH
1620
+ );
1621
+ appendItems(record.targetUsers, value);
1622
+ record.fieldSources.target_users = "llm";
1623
+ break;
1624
+ }
1625
+ case "outcome": {
1626
+ const value = assertSuggestionString(proposal2.value, "outcome", MAX_STRING_FIELD_LENGTH);
1627
+ record.expectedOutcome = value;
1628
+ record.fieldSources.expected_outcome = "llm";
1629
+ break;
1630
+ }
1631
+ case "question": {
1632
+ const template = proposal2.value;
1633
+ if (typeof template === "object" && template !== null && typeof template.field === "string" && typeof template.question === "string") {
1634
+ upsertQuestion(record, {
1635
+ field: template.field,
1636
+ question: template.question,
1637
+ required: template.required
1638
+ });
1639
+ }
1640
+ break;
1641
+ }
1642
+ }
1643
+ }
1644
+ updateMeta(ctx, action, fields, expectedVersion) {
1645
+ return {
1646
+ actorId: ctx.id,
1647
+ actorType: ctx.type,
1648
+ action,
1649
+ fields,
1650
+ ...expectedVersion !== void 0 ? { expectedVersion } : {}
1651
+ };
1652
+ }
1653
+ afterMutation(record, ctx, event) {
1654
+ this.logger.info("intake", "intake.updated", {
1655
+ intakeId: record.id,
1656
+ projectId: record.projectId,
1657
+ actorId: ctx.id
1658
+ });
1659
+ this.emit(event, {
1660
+ intakeId: record.id,
1661
+ projectId: record.projectId,
1662
+ actorId: ctx.id,
1663
+ actorType: ctx.type,
1664
+ status: record.status
1665
+ });
1666
+ }
1667
+ emit(event, data) {
1668
+ this.emitter.emit(event, data);
1669
+ }
1670
+ guardValidation(fn) {
1671
+ try {
1672
+ return fn();
1673
+ } catch (error) {
1674
+ if (error instanceof IntakeValidationError) {
1675
+ this.metrics.increment("intake.validation_failure");
1676
+ }
1677
+ throw error;
1678
+ }
1679
+ }
1680
+ };
1681
+ function applyOptionalString(record, field, value) {
1682
+ if (value === void 0) return;
1683
+ const trimmed = value.trim();
1684
+ if (trimmed.length === 0) {
1685
+ delete record[field];
1686
+ } else {
1687
+ record[field] = trimmed;
1688
+ }
1689
+ }
1690
+ function markUserSources(record, changedKeys) {
1691
+ const mapping = {
1692
+ title: "title",
1693
+ requestType: "request_type",
1694
+ priority: "priority",
1695
+ businessGoal: "business_goal",
1696
+ targetUsers: "target_users",
1697
+ expectedOutcome: "expected_outcome",
1698
+ scopeNotes: "scope_notes",
1699
+ constraints: "constraints",
1700
+ providedContext: "provided_context"
1701
+ };
1702
+ for (const key of changedKeys) {
1703
+ const sourceField = mapping[key];
1704
+ if (sourceField) {
1705
+ record.fieldSources[sourceField] = "user";
1706
+ }
1707
+ }
1708
+ }
1709
+ function markQuestionAnswered(record, field, value) {
1710
+ const question = record.questions.find((candidate) => candidate.field === field);
1711
+ if (question && question.status === "unanswered") {
1712
+ question.status = "answered";
1713
+ question.answer = value;
1714
+ }
1715
+ }
1716
+ export {
1717
+ ALLOWED_TRANSITIONS,
1718
+ AllowAllIntakeAuthorizer,
1719
+ DEFAULT_INTAKE_QUESTIONS,
1720
+ DenyAllIntakeAuthorizer,
1721
+ INTAKE_ATTACHMENT_KINDS,
1722
+ INTAKE_COUNTERS,
1723
+ INTAKE_EVENT_NAMES,
1724
+ INTAKE_FIELDS,
1725
+ INTAKE_FIELD_SOURCES,
1726
+ INTAKE_ID_PREFIX,
1727
+ INTAKE_OPERATIONS,
1728
+ INTAKE_PRIORITIES,
1729
+ INTAKE_QUESTION_STATUSES,
1730
+ INTAKE_STATUSES,
1731
+ INTAKE_TIMERS,
1732
+ InMemoryIntakeLogger,
1733
+ InMemoryIntakeMetrics,
1734
+ IntakeAuthorizationError,
1735
+ IntakeConflictError,
1736
+ IntakeError,
1737
+ IntakeEventEmitter,
1738
+ IntakeNotFoundError,
1739
+ IntakeStateTransitionError,
1740
+ IntakeStatusLockedError,
1741
+ IntakeSuggestionError,
1742
+ IntakeValidationError,
1743
+ MAX_ANSWER_LENGTH,
1744
+ MAX_ARRAY_ITEMS,
1745
+ MAX_ATTACHMENTS,
1746
+ MAX_HISTORY_ENTRIES,
1747
+ MAX_IDEMPOTENCY_KEY_LENGTH,
1748
+ MAX_METADATA_BYTES,
1749
+ MAX_METADATA_ENTRIES,
1750
+ MAX_QUESTION_LENGTH,
1751
+ MAX_REFERENCE_LENGTH,
1752
+ MAX_RELATED_RESOURCES,
1753
+ MAX_REQUEST_LENGTH,
1754
+ MAX_STRING_FIELD_LENGTH,
1755
+ MAX_SUGGESTIONS,
1756
+ MAX_SUMMARY_LENGTH,
1757
+ MAX_TITLE_LENGTH,
1758
+ MUTABLE_STATUSES,
1759
+ NoopIntakeLogger,
1760
+ NoopIntakeMetrics,
1761
+ ProjectMembershipIntakeAuthorizer,
1762
+ RELATED_RESOURCE_KINDS,
1763
+ REQUEST_TYPES,
1764
+ RequirementIntakeService,
1765
+ RequirementIntakeStore,
1766
+ SUGGESTION_KINDS,
1767
+ SUGGESTION_STATUSES,
1768
+ answerInputSchema,
1769
+ assertSuggestionString,
1770
+ assertTransition,
1771
+ attachResourceInputSchema,
1772
+ attachmentInputSchema,
1773
+ buildInitialQuestions,
1774
+ canTransition,
1775
+ createIntakeSchema,
1776
+ deterministicSummary,
1777
+ deterministicTitle,
1778
+ isBlank,
1779
+ isKnownStatus,
1780
+ isMutableStatus,
1781
+ isTerminalStatus,
1782
+ llmSuggestionOutputSchema,
1783
+ metadataSchema,
1784
+ newIntakeId,
1785
+ normalizeRequestType,
1786
+ parseWithIssues,
1787
+ pendingQuestions,
1788
+ prioritySchema,
1789
+ questionTemplateInputSchema,
1790
+ relatedResourceInputSchema,
1791
+ requestTypeSchema,
1792
+ toProposals,
1793
+ updateIntakeSchema,
1794
+ upsertQuestion,
1795
+ validateAnswerInput,
1796
+ validateAttachResourceInput,
1797
+ validateAttachmentInput,
1798
+ validateCreateInput,
1799
+ validateFieldSource,
1800
+ validateLlmSuggestionOutput,
1801
+ validateQuestionTemplateInput,
1802
+ validateRelatedResourceInput,
1803
+ validateUpdateInput
1804
+ };
1805
+ //# sourceMappingURL=index.js.map