@wrongstack/requirement-intake 1.0.29 → 1.0.32

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 CHANGED
@@ -28,14 +28,14 @@ var ProjectMembershipIntakeAuthorizer = class {
28
28
  ownerOnly;
29
29
  constructor(options) {
30
30
  this.projectsOf = options.projectsOf;
31
- this.ownerOnly = options.ownerOnlyOperations ?? /* @__PURE__ */ new Set();
31
+ this.ownerOnly = new Set(options.ownerOnlyOperations);
32
32
  }
33
33
  async isAllowed(operation, ctx, record) {
34
34
  if (record && record.projectId !== ctx.projectId) return false;
35
35
  const memberships = await this.projectsOf(ctx.id, ctx.type);
36
36
  if (!memberships.has(ctx.projectId)) return false;
37
- if (this.ownerOnly.has(operation) && record) {
38
- return record.requestedBy === ctx.id;
37
+ if (this.ownerOnly.has(operation)) {
38
+ return record !== void 0 && record.requestedBy === ctx.id;
39
39
  }
40
40
  return true;
41
41
  }
@@ -215,7 +215,11 @@ var IntakeEventEmitter = class {
215
215
  listeners = /* @__PURE__ */ new Set();
216
216
  /** Publish an event. Listener errors are swallowed so one bad listener cannot break others. */
217
217
  emit(event, data) {
218
- const payload = { event, timestamp: (/* @__PURE__ */ new Date()).toISOString(), ...data };
218
+ const payload = Object.freeze({
219
+ ...data,
220
+ event,
221
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
222
+ });
219
223
  for (const listener of [...this.listeners]) {
220
224
  try {
221
225
  listener(payload);
@@ -253,6 +257,7 @@ var ALLOWED_TRANSITIONS = {
253
257
  };
254
258
  var MUTABLE_STATUSES = ["draft", "collecting_information"];
255
259
  function canTransition(from, to) {
260
+ if (!Object.hasOwn(ALLOWED_TRANSITIONS, from)) return false;
256
261
  return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
257
262
  }
258
263
  function assertTransition(from, to) {
@@ -271,6 +276,9 @@ function isKnownStatus(value) {
271
276
  }
272
277
 
273
278
  // src/logger.ts
279
+ function snapshotFields(fields) {
280
+ return fields === void 0 ? void 0 : { ...fields };
281
+ }
274
282
  var NoopIntakeLogger = class {
275
283
  info(_scope, _message, _fields) {
276
284
  }
@@ -286,7 +294,7 @@ var InMemoryIntakeLogger = class {
286
294
  level: "info",
287
295
  scope,
288
296
  message,
289
- ...fields !== void 0 ? { fields } : {}
297
+ ...fields !== void 0 ? { fields: snapshotFields(fields) } : {}
290
298
  });
291
299
  }
292
300
  warn(scope, message, fields) {
@@ -294,7 +302,7 @@ var InMemoryIntakeLogger = class {
294
302
  level: "warn",
295
303
  scope,
296
304
  message,
297
- ...fields !== void 0 ? { fields } : {}
305
+ ...fields !== void 0 ? { fields: snapshotFields(fields) } : {}
298
306
  });
299
307
  }
300
308
  error(scope, message, fields) {
@@ -302,7 +310,7 @@ var InMemoryIntakeLogger = class {
302
310
  level: "error",
303
311
  scope,
304
312
  message,
305
- ...fields !== void 0 ? { fields } : {}
313
+ ...fields !== void 0 ? { fields: snapshotFields(fields) } : {}
306
314
  });
307
315
  }
308
316
  };
@@ -377,7 +385,7 @@ function makeQuestion(template, order, skipped) {
377
385
  function buildInitialQuestions(input, catalog = DEFAULT_INTAKE_QUESTIONS) {
378
386
  const known = new Set((input.knownFields ?? []).map((field) => field.trim()));
379
387
  return catalog.map((template, index) => {
380
- const answeredByInput = FIELD_TO_INPUT_PROPERTY[template.field]?.(input) ?? false;
388
+ const answeredByInput = Object.hasOwn(FIELD_TO_INPUT_PROPERTY, template.field) ? FIELD_TO_INPUT_PROPERTY[template.field]?.(input) ?? false : false;
381
389
  const skipped = answeredByInput || known.has(template.field);
382
390
  return makeQuestion(template, index, skipped);
383
391
  });
@@ -411,6 +419,11 @@ var IDEMPOTENCY_PATH = "_idempotency.json";
411
419
  function hashIdempotencyKey(key) {
412
420
  return createHash("sha256").update(key, "utf8").digest("hex");
413
421
  }
422
+ function positiveIntegerOrDefault(value, fallback) {
423
+ if (value === void 0 || !Number.isFinite(value)) return fallback;
424
+ const normalized = Math.floor(value);
425
+ return Number.isSafeInteger(normalized) && normalized >= 1 ? normalized : fallback;
426
+ }
414
427
  function indexEntryFor(record) {
415
428
  return {
416
429
  id: record.id,
@@ -434,7 +447,7 @@ var RequirementIntakeStore = class {
434
447
  this.baseDir = options.baseDir ?? resolveWstackPaths({ projectRoot: process.cwd() }).projectRequirementIntakes;
435
448
  this.indexPath = path.join(this.baseDir, INDEX_PATH);
436
449
  this.idempotencyPath = path.join(this.baseDir, IDEMPOTENCY_PATH);
437
- this.maxIdempotencyEntries = options.maxIdempotencyEntries ?? 1e4;
450
+ this.maxIdempotencyEntries = positiveIntegerOrDefault(options.maxIdempotencyEntries, 1e4);
438
451
  this.lockTimeoutMs = options.lockTimeoutMs ?? 15e3;
439
452
  }
440
453
  get directory() {
@@ -544,6 +557,7 @@ var RequirementIntakeStore = class {
544
557
  }
545
558
  const next = structuredClone(current);
546
559
  await mutate(next);
560
+ next.id = current.id;
547
561
  next.version = current.version + 1;
548
562
  next.updatedAt = Date.now();
549
563
  this.appendHistory(next, options);
@@ -617,20 +631,73 @@ var RequirementIntakeStore = class {
617
631
  } else {
618
632
  index.entries.push(entry);
619
633
  }
620
- await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
634
+ try {
635
+ await atomicWrite(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
636
+ } catch (error) {
637
+ try {
638
+ await fsp.unlink(this.indexPath);
639
+ } catch {
640
+ }
641
+ throw error;
642
+ }
621
643
  },
622
644
  { timeoutMs: this.lockTimeoutMs }
623
645
  );
624
646
  }
625
647
  async readIdempotency() {
648
+ let raw;
626
649
  try {
627
- const raw = await fsp.readFile(this.idempotencyPath, "utf8");
628
- const parsed = JSON.parse(raw);
629
- if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object")
630
- return parsed;
631
- } catch {
650
+ raw = await fsp.readFile(this.idempotencyPath, "utf8");
651
+ } catch (error) {
652
+ if (error.code === "ENOENT") return { version: 1, entries: {} };
653
+ throw error;
654
+ }
655
+ let parsed;
656
+ try {
657
+ parsed = JSON.parse(raw);
658
+ } catch (error) {
659
+ if (error instanceof SyntaxError) return this.recoverIdempotency();
660
+ throw error;
661
+ }
662
+ if (parsed?.version !== 1 || !parsed.entries || typeof parsed.entries !== "object" || Array.isArray(parsed.entries)) {
663
+ return this.recoverIdempotency();
664
+ }
665
+ return parsed;
666
+ }
667
+ /** Rebuild a lost/corrupt idempotency index from its durable record files. */
668
+ async recoverIdempotency() {
669
+ let files;
670
+ try {
671
+ files = await fsp.readdir(this.baseDir);
672
+ } catch (error) {
673
+ if (error.code === "ENOENT") return { version: 1, entries: {} };
674
+ throw error;
675
+ }
676
+ const entries = {};
677
+ for (const file of files) {
678
+ if (file === INDEX_PATH || file === IDEMPOTENCY_PATH || !file.endsWith(".json")) continue;
679
+ const id = file.slice(0, -".json".length);
680
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) continue;
681
+ let record;
682
+ try {
683
+ record = JSON.parse(
684
+ await fsp.readFile(path.join(this.baseDir, file), "utf8")
685
+ );
686
+ } catch (error) {
687
+ if (error.code === "ENOENT") continue;
688
+ throw error;
689
+ }
690
+ if (record?.id !== id) throw new Error(`Invalid requirement intake record: ${file}`);
691
+ const key = record.idempotencyKey;
692
+ if (typeof key !== "string" || key.trim().length === 0) continue;
693
+ const digest = hashIdempotencyKey(key.trim());
694
+ if (!Object.hasOwn(entries, digest)) {
695
+ entries[digest] = { intakeId: record.id, createdAt: record.createdAt };
696
+ }
632
697
  }
633
- return { version: 1, entries: {} };
698
+ const recovered = { version: 1, entries };
699
+ await this.pruneIdempotency(recovered);
700
+ return recovered;
634
701
  }
635
702
  async pruneIdempotency(map) {
636
703
  const entries = Object.entries(map.entries);
@@ -658,7 +725,7 @@ var requestTypeSchema = z.enum(REQUEST_TYPES);
658
725
  var prioritySchema = z.enum(INTAKE_PRIORITIES);
659
726
  var intakeQuestionStatusSchema = z.enum(INTAKE_QUESTION_STATUSES);
660
727
  var fieldSourceSchema = z.enum(["user", "llm", "deterministic", "system"]);
661
- var httpUrlSchema = z.string().max(MAX_REFERENCE_LENGTH).refine((value) => {
728
+ var httpUrlSchema = z.string().trim().max(MAX_REFERENCE_LENGTH).refine((value) => {
662
729
  try {
663
730
  const url = new URL(value);
664
731
  return url.protocol === "http:" || url.protocol === "https:";
@@ -666,26 +733,30 @@ var httpUrlSchema = z.string().max(MAX_REFERENCE_LENGTH).refine((value) => {
666
733
  return false;
667
734
  }
668
735
  }, "must be a valid http(s) URL");
669
- var idSchema = z.string().min(1).max(128).refine((value) => value.trim().length > 0, "must not be blank");
736
+ var idSchema = z.string().trim().min(1).max(128);
670
737
  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`);
738
+ var normalizedOptionalString = (max) => z.string().trim().max(max).transform((value) => value.length > 0 ? value : void 0).optional();
671
739
  var attachmentInputSchema = z.object({
672
- name: nonBlankString(200, "attachment name"),
740
+ name: z.string().trim().min(1).max(200),
673
741
  kind: z.enum(INTAKE_ATTACHMENT_KINDS),
674
- path: z.string().max(MAX_REFERENCE_LENGTH).optional(),
742
+ path: z.string().trim().min(1).max(MAX_REFERENCE_LENGTH).optional(),
675
743
  url: httpUrlSchema.optional(),
676
744
  sizeBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
677
- mimeType: z.string().max(100).optional()
745
+ mimeType: normalizedOptionalString(100)
678
746
  }).refine((value) => value.path === void 0 !== (value.url === void 0), {
679
747
  message: "exactly one of path or url is required"
680
748
  });
681
749
  var relatedResourceInputSchema = z.object({
682
750
  kind: z.enum(RELATED_RESOURCE_KINDS),
683
- reference: nonBlankString(MAX_REFERENCE_LENGTH, "related resource reference"),
684
- title: z.string().max(200).optional()
751
+ reference: z.string().trim().min(1).max(MAX_REFERENCE_LENGTH),
752
+ title: normalizedOptionalString(200)
753
+ }).refine((value) => value.kind !== "url" || httpUrlSchema.safeParse(value.reference).success, {
754
+ path: ["reference"],
755
+ message: "URL resource reference must be a valid http(s) URL"
685
756
  });
686
757
  var questionTemplateInputSchema = z.object({
687
- field: nonBlankString(64, "question field"),
688
- question: nonBlankString(MAX_QUESTION_LENGTH, "question text"),
758
+ field: z.string().trim().min(1).max(64),
759
+ question: z.string().trim().min(1).max(MAX_QUESTION_LENGTH),
689
760
  required: z.boolean().optional()
690
761
  });
691
762
  var metadataSchema = z.record(z.string().max(64), z.unknown()).superRefine((value, ctx) => {
@@ -762,12 +833,9 @@ var updateIntakeSchema = z.object({
762
833
  vibeProtocol: vibeProtocolStateSchema.optional()
763
834
  }).strict();
764
835
  var answerInputSchema = z.object({
765
- field: nonBlankString(64, "answer field"),
766
- answer: nonBlankString(MAX_ANSWER_LENGTH, "answer").max(
767
- MAX_ANSWER_LENGTH,
768
- `answer exceeds the maximum length of ${MAX_ANSWER_LENGTH} characters`
769
- ),
770
- question: z.string().max(MAX_QUESTION_LENGTH).optional()
836
+ field: z.string().trim().min(1, "answer field must not be empty or whitespace-only").max(64, "answer field exceeds the maximum length of 64 characters"),
837
+ answer: z.string().trim().min(1).max(MAX_ANSWER_LENGTH),
838
+ question: z.string().trim().max(MAX_QUESTION_LENGTH).transform((value) => value.length > 0 ? value : void 0).optional()
771
839
  });
772
840
  var attachResourceInputSchema = z.object({
773
841
  attachment: attachmentInputSchema.optional(),
@@ -825,16 +893,20 @@ function validateFieldSource(value) {
825
893
  throw new IntakeValidationError([{ field: "source", message: "invalid field source" }]);
826
894
  }
827
895
  function deterministicSummary(originalRequest, maxLength = 240) {
896
+ const limit = Number.isFinite(maxLength) ? Math.max(0, Math.floor(maxLength)) : 0;
897
+ if (limit === 0) return "";
828
898
  const collapsed = originalRequest.replace(/\s+/g, " ").trim();
829
- if (collapsed.length <= maxLength) return collapsed;
830
- return `${collapsed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
899
+ if (collapsed.length <= limit) return collapsed;
900
+ return `${collapsed.slice(0, limit - 1).trimEnd()}\u2026`;
831
901
  }
832
902
  function deterministicTitle(originalRequest, maxLength = MAX_TITLE_LENGTH) {
903
+ const limit = Number.isFinite(maxLength) ? Math.max(0, Math.floor(maxLength)) : 0;
904
+ if (limit === 0) return "";
833
905
  const newlineIdx = originalRequest.search(/\r?\n/);
834
906
  const firstLine = (newlineIdx >= 0 ? originalRequest.slice(0, newlineIdx) : originalRequest).trim();
835
- if (firstLine.length === 0) return "Untitled request";
836
- if (firstLine.length <= maxLength) return firstLine;
837
- return `${firstLine.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
907
+ const title = firstLine.length === 0 ? "Untitled request" : firstLine;
908
+ if (title.length <= limit) return title;
909
+ return `${title.slice(0, limit - 1).trimEnd()}\u2026`;
838
910
  }
839
911
 
840
912
  // src/suggestions.ts
@@ -913,12 +985,13 @@ function toProposals(suggestion) {
913
985
  return proposals;
914
986
  }
915
987
  function assertSuggestionString(value, label, max) {
916
- if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
988
+ const trimmed = typeof value === "string" ? value.trim() : "";
989
+ if (trimmed.length === 0 || trimmed.length > max) {
917
990
  throw new IntakeValidationError([
918
991
  { field: `suggestion.${label}`, message: `invalid ${label} suggestion value` }
919
992
  ]);
920
993
  }
921
- return value;
994
+ return trimmed;
922
995
  }
923
996
 
924
997
  // src/vibe.ts
@@ -928,14 +1001,19 @@ function hasVibeTag(text) {
928
1001
  return VIBE_TAG_REGEX.test(text);
929
1002
  }
930
1003
  function stripVibeTag(text) {
931
- return text.replace(/\[VIBE\]/gi, "").replace(/[ \t]{2,}/g, " ").trim();
1004
+ return text.replace(/(?:\[VIBE\])+/gi, (tags, offset, source) => {
1005
+ const before = source[offset - 1];
1006
+ const after = source[offset + tags.length];
1007
+ return before !== void 0 && after !== void 0 && /[\p{L}\p{N}_]/u.test(before) && /[\p{L}\p{N}_]/u.test(after) ? " " : "";
1008
+ }).replace(/[ \t]{2,}/g, " ").trim();
932
1009
  }
933
1010
  function deriveVibeState(rawText, existingState, now = Date.now()) {
934
1011
  const containsTag = hasVibeTag(rawText);
935
1012
  if (existingState?.isVibeMode) {
936
1013
  return {
937
1014
  ...existingState,
938
- isVibeMode: true
1015
+ isVibeMode: true,
1016
+ ...existingState.auditNotes !== void 0 ? { auditNotes: [...existingState.auditNotes] } : {}
939
1017
  };
940
1018
  }
941
1019
  if (containsTag) {
@@ -1013,6 +1091,9 @@ function buildNewIntakeRecord(input, ctx, now, catalog) {
1013
1091
  const title = titleProvided ? input.title.trim() : deterministicTitle(input.originalRequest);
1014
1092
  const requestType = normalizeRequestType(input.requestType);
1015
1093
  const idempotencyKey = input.idempotencyKey?.trim();
1094
+ const businessGoal = input.businessGoal?.trim();
1095
+ const expectedOutcome = input.expectedOutcome?.trim();
1096
+ const scopeNotes = input.scopeNotes?.trim();
1016
1097
  const attachments = (input.attachments ?? []).map((attachment) => ({
1017
1098
  id: `${ATTACHMENT_ID_PREFIX}${ulid4()}`,
1018
1099
  name: attachment.name,
@@ -1050,10 +1131,10 @@ function buildNewIntakeRecord(input, ctx, now, catalog) {
1050
1131
  priority: input.priority ?? "unspecified",
1051
1132
  requestedBy: input.requestedBy,
1052
1133
  ...vibeState ? { isVibeMode: true, vibeProtocol: vibeState } : {},
1053
- ...input.businessGoal !== void 0 ? { businessGoal: input.businessGoal } : {},
1134
+ ...businessGoal ? { businessGoal } : {},
1054
1135
  targetUsers: [...input.targetUsers ?? []],
1055
- ...input.expectedOutcome !== void 0 ? { expectedOutcome: input.expectedOutcome } : {},
1056
- ...input.scopeNotes !== void 0 ? { scopeNotes: input.scopeNotes } : {},
1136
+ ...expectedOutcome ? { expectedOutcome } : {},
1137
+ ...scopeNotes ? { scopeNotes } : {},
1057
1138
  constraints: [...input.constraints ?? []],
1058
1139
  providedContext: [...input.providedContext ?? []],
1059
1140
  attachments,
@@ -1067,10 +1148,10 @@ function buildNewIntakeRecord(input, ctx, now, catalog) {
1067
1148
  normalized_summary: "deterministic",
1068
1149
  request_type: input.requestType !== void 0 ? "user" : "deterministic",
1069
1150
  priority: input.priority !== void 0 ? "user" : "deterministic",
1070
- ...input.businessGoal !== void 0 ? { business_goal: "user" } : {},
1151
+ ...businessGoal ? { business_goal: "user" } : {},
1071
1152
  ...input.targetUsers !== void 0 ? { target_users: "user" } : {},
1072
- ...input.expectedOutcome !== void 0 ? { expected_outcome: "user" } : {},
1073
- ...input.scopeNotes !== void 0 ? { scope_notes: "user" } : {},
1153
+ ...expectedOutcome ? { expected_outcome: "user" } : {},
1154
+ ...scopeNotes ? { scope_notes: "user" } : {},
1074
1155
  ...input.constraints !== void 0 ? { constraints: "user" } : {},
1075
1156
  ...input.providedContext !== void 0 ? { provided_context: "user" } : {},
1076
1157
  ...attachments.length > 0 ? { attachments: "user" } : {},
@@ -1327,13 +1408,14 @@ var RequirementIntakeService = class {
1327
1408
  async createIntake(input, ctx) {
1328
1409
  const validated = this.guardValidation(() => validateCreateInput(input));
1329
1410
  if (validated.projectId !== ctx.projectId) {
1330
- throw new IntakeAuthorizationError("create", ctx.id, validated.projectId);
1411
+ this.denyUnauthorized("create", ctx, validated.projectId);
1331
1412
  }
1332
1413
  await this.authorize("create", ctx);
1333
1414
  if (validated.idempotencyKey !== void 0 && validated.idempotencyKey.trim().length > 0) {
1334
1415
  const existing = await this.store.findByIdempotencyKey(validated.idempotencyKey);
1335
1416
  if (existing) {
1336
1417
  if (existing.projectId !== ctx.projectId) {
1418
+ this.recordUnauthorizedAttempt();
1337
1419
  throw new IntakeValidationError([
1338
1420
  {
1339
1421
  field: "idempotencyKey",
@@ -1355,6 +1437,7 @@ var RequirementIntakeService = class {
1355
1437
  const result = await this.store.create(record, validated.idempotencyKey?.trim());
1356
1438
  if (!result.created) {
1357
1439
  if (result.record.projectId !== ctx.projectId) {
1440
+ this.recordUnauthorizedAttempt();
1358
1441
  throw new IntakeValidationError([
1359
1442
  {
1360
1443
  field: "idempotencyKey",
@@ -1396,7 +1479,7 @@ var RequirementIntakeService = class {
1396
1479
  }
1397
1480
  async listIntakes(projectId, ctx, filter) {
1398
1481
  if (projectId !== ctx.projectId) {
1399
- throw new IntakeAuthorizationError("list", ctx.id, projectId);
1482
+ this.denyUnauthorized("list", ctx, projectId);
1400
1483
  }
1401
1484
  await this.authorize("list", ctx);
1402
1485
  return this.store.list(projectId, filter);
@@ -1451,7 +1534,7 @@ var RequirementIntakeService = class {
1451
1534
  const record = await this.requireRecord(id, ctx, "answer");
1452
1535
  this.assertMutable(record, "addAnswer");
1453
1536
  const validated = this.guardValidation(() => validateAnswerInput(input));
1454
- this.assertAnswerField(validated.field);
1537
+ this.assertAnswerField(record, validated.field);
1455
1538
  return this.store.update(
1456
1539
  id,
1457
1540
  this.updateMeta(ctx, "answer_added", [validated.field], expectedVersion),
@@ -1547,30 +1630,27 @@ var RequirementIntakeService = class {
1547
1630
  throw new IntakeSuggestionError("LLM suggestion output contained no usable proposals");
1548
1631
  }
1549
1632
  const hadQuestions = proposals.some((proposal2) => proposal2.kind === "question");
1550
- const previousStatus = record.status;
1551
- const nextStatus = previousStatus === "draft" ? "collecting_information" : previousStatus;
1552
- const updated = await this.store.update(
1553
- id,
1554
- {
1555
- actorId: ctx.id,
1556
- actorType: ctx.type,
1557
- action: hadQuestions ? "information_requested" : "suggestions_added",
1558
- from: previousStatus === nextStatus ? void 0 : previousStatus,
1559
- to: previousStatus === nextStatus ? void 0 : nextStatus
1560
- },
1561
- (next) => {
1562
- this.assertMutable(next, "generateSuggestions");
1563
- next.llmSuggestions.push(...proposals);
1564
- if (next.llmSuggestions.length > MAX_SUGGESTIONS) {
1565
- next.llmSuggestions = next.llmSuggestions.slice(
1566
- next.llmSuggestions.length - MAX_SUGGESTIONS
1567
- );
1568
- }
1569
- if (previousStatus === "draft" && nextStatus === "collecting_information") {
1570
- next.status = nextStatus;
1571
- }
1633
+ let previousStatus = record.status;
1634
+ const updateOptions = {
1635
+ actorId: ctx.id,
1636
+ actorType: ctx.type,
1637
+ action: hadQuestions ? "information_requested" : "suggestions_added"
1638
+ };
1639
+ const updated = await this.store.update(id, updateOptions, (next) => {
1640
+ this.assertMutable(next, "generateSuggestions");
1641
+ previousStatus = next.status;
1642
+ if (next.status === "draft") {
1643
+ updateOptions.from = "draft";
1644
+ updateOptions.to = "collecting_information";
1645
+ next.status = "collecting_information";
1572
1646
  }
1573
- );
1647
+ next.llmSuggestions.push(...proposals);
1648
+ if (next.llmSuggestions.length > MAX_SUGGESTIONS) {
1649
+ next.llmSuggestions = next.llmSuggestions.slice(
1650
+ next.llmSuggestions.length - MAX_SUGGESTIONS
1651
+ );
1652
+ }
1653
+ });
1574
1654
  this.metrics.increment("intake.suggestions.succeeded");
1575
1655
  this.logger.info("intake", "intake.suggestions.succeeded", {
1576
1656
  intakeId: updated.id,
@@ -1657,24 +1737,25 @@ var RequirementIntakeService = class {
1657
1737
  assertTransition(record.status, "submitted");
1658
1738
  this.assertSubmitReady(record);
1659
1739
  const now = Date.now();
1740
+ let previousStatus = record.status;
1741
+ const updateOptions = {
1742
+ actorId: ctx.id,
1743
+ actorType: ctx.type,
1744
+ action: "submitted",
1745
+ expectedVersion: expectedVersion ?? record.version
1746
+ };
1660
1747
  try {
1661
- const updated = await this.store.update(
1662
- id,
1663
- {
1664
- actorId: ctx.id,
1665
- actorType: ctx.type,
1666
- action: "submitted",
1667
- from: record.status,
1668
- to: "submitted",
1669
- expectedVersion: expectedVersion ?? record.version
1670
- },
1671
- (next) => {
1672
- next.status = "submitted";
1673
- next.submittedAt = now;
1674
- next.submittedBy = ctx.id;
1675
- next.submittedByType = ctx.type;
1676
- }
1677
- );
1748
+ const updated = await this.store.update(id, updateOptions, (next) => {
1749
+ assertTransition(next.status, "submitted");
1750
+ this.assertSubmitReady(next);
1751
+ previousStatus = next.status;
1752
+ updateOptions.from = next.status;
1753
+ updateOptions.to = "submitted";
1754
+ next.status = "submitted";
1755
+ next.submittedAt = now;
1756
+ next.submittedBy = ctx.id;
1757
+ next.submittedByType = ctx.type;
1758
+ });
1678
1759
  this.metrics.increment("intake.submitted");
1679
1760
  this.metrics.recordDuration("intake.time_to_submit", now - updated.createdAt);
1680
1761
  this.logger.info("intake", "intake.submitted", {
@@ -1687,7 +1768,7 @@ var RequirementIntakeService = class {
1687
1768
  projectId: updated.projectId,
1688
1769
  actorId: ctx.id,
1689
1770
  actorType: ctx.type,
1690
- previousStatus: record.status,
1771
+ previousStatus,
1691
1772
  status: "submitted"
1692
1773
  });
1693
1774
  return { record: updated, idempotent: false };
@@ -1775,7 +1856,11 @@ var RequirementIntakeService = class {
1775
1856
  // Internals
1776
1857
  // -------------------------------------------------------------------------
1777
1858
  buildNewRecord(input, ctx, now) {
1778
- return buildNewIntakeRecord(input, ctx, now, this.catalog);
1859
+ const catalog = input.questions?.map((question) => ({
1860
+ ...question,
1861
+ required: question.required ?? false
1862
+ }));
1863
+ return buildNewIntakeRecord(input, ctx, now, catalog ?? this.catalog);
1779
1864
  }
1780
1865
  async requireRecord(id, ctx, operation) {
1781
1866
  const record = await this.store.load(id);
@@ -1786,18 +1871,25 @@ var RequirementIntakeService = class {
1786
1871
  async authorize(operation, ctx, record) {
1787
1872
  const allowed = await this.authorizer.isAllowed(operation, ctx, record);
1788
1873
  if (!allowed) {
1789
- this.metrics.increment("intake.unauthorized_attempt");
1790
- throw new IntakeAuthorizationError(operation, ctx.id, ctx.projectId);
1874
+ this.denyUnauthorized(operation, ctx, ctx.projectId);
1791
1875
  }
1792
1876
  }
1877
+ denyUnauthorized(operation, ctx, projectId) {
1878
+ this.recordUnauthorizedAttempt();
1879
+ throw new IntakeAuthorizationError(operation, ctx.id, projectId);
1880
+ }
1881
+ recordUnauthorizedAttempt() {
1882
+ this.metrics.increment("intake.unauthorized_attempt");
1883
+ }
1793
1884
  assertMutable(record, action) {
1794
1885
  if (!isMutableStatus(record.status)) {
1795
1886
  throw new IntakeStatusLockedError(record.id, record.status, action);
1796
1887
  }
1797
1888
  }
1798
- assertAnswerField(field) {
1889
+ assertAnswerField(record, field) {
1799
1890
  const catalogFields = new Set(this.catalog.map((template) => template.field));
1800
- if (!catalogFields.has(field) && !INTAKE_FIELDS.includes(field)) {
1891
+ const recordHasField = record.questions.some((question) => question.field === field);
1892
+ if (!recordHasField && !catalogFields.has(field) && !INTAKE_FIELDS.includes(field)) {
1801
1893
  throw new IntakeValidationError([
1802
1894
  { field: "field", message: `unknown intake field: ${field}` }
1803
1895
  ]);
package/dist/service.d.ts CHANGED
@@ -74,6 +74,8 @@ export declare class RequirementIntakeService {
74
74
  private buildNewRecord;
75
75
  private requireRecord;
76
76
  private authorize;
77
+ private denyUnauthorized;
78
+ private recordUnauthorizedAttempt;
77
79
  private assertMutable;
78
80
  private assertAnswerField;
79
81
  private assertSubmitReady;
package/dist/store.d.ts CHANGED
@@ -74,6 +74,8 @@ export declare class RequirementIntakeStore {
74
74
  private readIndex;
75
75
  private updateIndexFor;
76
76
  private readIdempotency;
77
+ /** Rebuild a lost/corrupt idempotency index from its durable record files. */
78
+ private recoverIdempotency;
77
79
  private pruneIdempotency;
78
80
  }
79
81
  /** Generate a store-compatible record id (`reqi_<ulid>`). */
@@ -55,7 +55,7 @@ export declare const attachmentInputSchema: z.ZodObject<{
55
55
  path: z.ZodOptional<z.ZodString>;
56
56
  url: z.ZodOptional<z.ZodString>;
57
57
  sizeBytes: z.ZodOptional<z.ZodNumber>;
58
- mimeType: z.ZodOptional<z.ZodString>;
58
+ mimeType: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
59
59
  }, z.core.$strip>;
60
60
  export declare const relatedResourceInputSchema: z.ZodObject<{
61
61
  kind: z.ZodEnum<{
@@ -67,7 +67,7 @@ export declare const relatedResourceInputSchema: z.ZodObject<{
67
67
  url: "url";
68
68
  }>;
69
69
  reference: z.ZodString;
70
- title: z.ZodOptional<z.ZodString>;
70
+ title: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
71
71
  }, z.core.$strip>;
72
72
  export declare const questionTemplateInputSchema: z.ZodObject<{
73
73
  field: z.ZodString;
@@ -124,7 +124,7 @@ export declare const createIntakeSchema: z.ZodObject<{
124
124
  path: z.ZodOptional<z.ZodString>;
125
125
  url: z.ZodOptional<z.ZodString>;
126
126
  sizeBytes: z.ZodOptional<z.ZodNumber>;
127
- mimeType: z.ZodOptional<z.ZodString>;
127
+ mimeType: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
128
128
  }, z.core.$strip>>>;
129
129
  relatedResources: z.ZodOptional<z.ZodArray<z.ZodObject<{
130
130
  kind: z.ZodEnum<{
@@ -136,7 +136,7 @@ export declare const createIntakeSchema: z.ZodObject<{
136
136
  url: "url";
137
137
  }>;
138
138
  reference: z.ZodString;
139
- title: z.ZodOptional<z.ZodString>;
139
+ title: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
140
140
  }, z.core.$strip>>>;
141
141
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
142
142
  idempotencyKey: z.ZodOptional<z.ZodString>;
@@ -204,7 +204,7 @@ export declare const updateIntakeSchema: z.ZodObject<{
204
204
  export declare const answerInputSchema: z.ZodObject<{
205
205
  field: z.ZodString;
206
206
  answer: z.ZodString;
207
- question: z.ZodOptional<z.ZodString>;
207
+ question: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
208
208
  }, z.core.$strip>;
209
209
  export declare const attachResourceInputSchema: z.ZodObject<{
210
210
  attachment: z.ZodOptional<z.ZodObject<{
@@ -219,7 +219,7 @@ export declare const attachResourceInputSchema: z.ZodObject<{
219
219
  path: z.ZodOptional<z.ZodString>;
220
220
  url: z.ZodOptional<z.ZodString>;
221
221
  sizeBytes: z.ZodOptional<z.ZodNumber>;
222
- mimeType: z.ZodOptional<z.ZodString>;
222
+ mimeType: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
223
223
  }, z.core.$strip>>;
224
224
  relatedResource: z.ZodOptional<z.ZodObject<{
225
225
  kind: z.ZodEnum<{
@@ -231,7 +231,7 @@ export declare const attachResourceInputSchema: z.ZodObject<{
231
231
  url: "url";
232
232
  }>;
233
233
  reference: z.ZodString;
234
- title: z.ZodOptional<z.ZodString>;
234
+ title: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string | undefined, string>>>;
235
235
  }, z.core.$strip>>;
236
236
  }, z.core.$strip>;
237
237
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/requirement-intake",
3
- "version": "1.0.29",
3
+ "version": "1.0.32",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack Requirements Intake — collect, preserve, validate, normalize, and submit unstructured software development requests as structured intake records. Upstream of spec-driven development: it never plans, specifies, or implements.",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "!dist/**/*.map"
28
28
  ],
29
29
  "dependencies": {
30
- "@wrongstack/core": "1.0.29",
30
+ "@wrongstack/core": "1.0.32",
31
31
  "zod": "4.6.2"
32
32
  },
33
33
  "devDependencies": {