@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 +187 -95
- package/dist/service.d.ts +2 -0
- package/dist/store.d.ts +2 -0
- package/dist/validation.d.ts +7 -7
- package/package.json +2 -2
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 =
|
|
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)
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
}
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
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:
|
|
684
|
-
title:
|
|
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:
|
|
688
|
-
question:
|
|
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:
|
|
766
|
-
answer:
|
|
767
|
-
|
|
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 <=
|
|
830
|
-
return `${collapsed.slice(0,
|
|
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
|
-
|
|
836
|
-
if (
|
|
837
|
-
return `${
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
...
|
|
1134
|
+
...businessGoal ? { businessGoal } : {},
|
|
1054
1135
|
targetUsers: [...input.targetUsers ?? []],
|
|
1055
|
-
...
|
|
1056
|
-
...
|
|
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
|
-
...
|
|
1151
|
+
...businessGoal ? { business_goal: "user" } : {},
|
|
1071
1152
|
...input.targetUsers !== void 0 ? { target_users: "user" } : {},
|
|
1072
|
-
...
|
|
1073
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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>`). */
|
package/dist/validation.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
30
|
+
"@wrongstack/core": "1.0.32",
|
|
31
31
|
"zod": "4.6.2"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|