opencode-codeops 2.2.1 → 2.3.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.
@@ -0,0 +1,1213 @@
1
+ /**
2
+ * Discovery rules and model.
3
+ *
4
+ * This module owns the publish contract, answer validation, rule evaluation,
5
+ * submit readiness, and question fingerprinting. The browser bundle and the
6
+ * Node server both import it, so "visible", "implied", "locked", and "ready"
7
+ * mean exactly the same thing on both sides.
8
+ *
9
+ * It is deliberately pure and browser-safe: no `node:` imports, no file
10
+ * system, no clock, no randomness. Every function either returns a value or a
11
+ * documented result object; nothing here throws on malformed input.
12
+ *
13
+ * The data model is JSON-friendly. A plan carries its groups and questions,
14
+ * while the mutable answers and lifecycle live in a separate `PlanState`
15
+ * object so the stored record, the HTTP payload, and the page share one
16
+ * representation.
17
+ *
18
+ * @module discovery-rules
19
+ */
20
+
21
+ /** Schema version written into every Discovery data file. */
22
+ export const DISCOVERY_SCHEMA_VERSION = 1
23
+
24
+ /** Every supported question type. */
25
+ export const QUESTION_TYPES = Object.freeze(["single", "multiple", "text", "single-other"])
26
+
27
+ /** Question types that carry options and therefore accept choice answers. */
28
+ export const CHOICE_TYPES = Object.freeze(["single", "multiple", "single-other"])
29
+
30
+ /** Every supported plan kind. */
31
+ export const PLAN_KINDS = Object.freeze(["plan", "requirements", "preflight"])
32
+
33
+ /** Pattern for question keys, group keys, and option ids. */
34
+ export const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/
35
+
36
+ /** Pattern for the lower-case slugs used in URLs and file names. */
37
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
38
+
39
+ /** Maximum length of a plan title, group title, or option label. */
40
+ export const TITLE_MAX = 200
41
+
42
+ /** Maximum length of every other free-text value. */
43
+ export const TEXT_MAX = 4000
44
+
45
+ /** Maximum length of the repository-relative artifact path. */
46
+ export const ARTIFACT_MAX = 300
47
+
48
+ /** Structural caps that keep one publish under the request body limit. */
49
+ export const LIMITS = Object.freeze({
50
+ groups: 50,
51
+ questions: 200,
52
+ optionsMin: 2,
53
+ optionsMax: 20,
54
+ evidence: 20,
55
+ })
56
+
57
+ /**
58
+ * Question fields that make up the fingerprint used to detect a changed
59
+ * question on republish. `group`, `showIf`, and `proposal` are deliberately
60
+ * absent: moving a question between groups or changing how it is revealed
61
+ * does not invalidate a stored answer.
62
+ */
63
+ export const FINGERPRINT_FIELDS = Object.freeze([
64
+ "type",
65
+ "title",
66
+ "why",
67
+ "evidence",
68
+ "options",
69
+ "recommendation",
70
+ ])
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // JSON Schema for the publish tool
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** A JSON Schema string leaf with an optional maximum length. */
77
+ function stringSchema(maxLength) {
78
+ return maxLength === undefined
79
+ ? { type: "string" }
80
+ : { type: "string", minLength: 1, maxLength }
81
+ }
82
+
83
+ /** A compact answer: an option id, a list of option ids, or free text. */
84
+ const COMPACT_ANSWER_SCHEMA = {
85
+ anyOf: [
86
+ { type: "string" },
87
+ { type: "array", minItems: 1, items: { type: "string" } },
88
+ ],
89
+ }
90
+
91
+ /** One `implies` entry attached to an option. */
92
+ const IMPLIES_SCHEMA = {
93
+ type: "object",
94
+ additionalProperties: false,
95
+ required: ["question", "answer", "reason"],
96
+ properties: {
97
+ question: stringSchema(),
98
+ answer: COMPACT_ANSWER_SCHEMA,
99
+ reason: { type: "string", minLength: 1, maxLength: TEXT_MAX },
100
+ },
101
+ }
102
+
103
+ /** One question option. */
104
+ const OPTION_SCHEMA = {
105
+ type: "object",
106
+ additionalProperties: false,
107
+ required: ["id", "label"],
108
+ properties: {
109
+ id: { type: "string", pattern: KEY_PATTERN.source },
110
+ label: { type: "string", minLength: 1, maxLength: TITLE_MAX },
111
+ description: { type: "string", maxLength: TEXT_MAX },
112
+ implies: { type: "array", items: IMPLIES_SCHEMA },
113
+ },
114
+ }
115
+
116
+ /** One visibility condition. */
117
+ const SHOW_IF_SCHEMA = {
118
+ type: "object",
119
+ additionalProperties: false,
120
+ required: ["question", "anyOf"],
121
+ properties: {
122
+ question: stringSchema(),
123
+ anyOf: { type: "array", minItems: 1, items: { type: "string" } },
124
+ },
125
+ }
126
+
127
+ /** An agent-proposed settlement for one question. */
128
+ const PROPOSAL_SCHEMA = {
129
+ type: "object",
130
+ additionalProperties: false,
131
+ required: ["answer", "reason"],
132
+ properties: {
133
+ answer: COMPACT_ANSWER_SCHEMA,
134
+ reason: { type: "string", minLength: 1, maxLength: TEXT_MAX },
135
+ },
136
+ }
137
+
138
+ /** One published group. */
139
+ const GROUP_SCHEMA = {
140
+ type: "object",
141
+ additionalProperties: false,
142
+ required: ["key", "title"],
143
+ properties: {
144
+ key: { type: "string", pattern: KEY_PATTERN.source },
145
+ title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
146
+ description: { type: "string", maxLength: TEXT_MAX },
147
+ dependsOn: { type: "array", items: { type: "string" } },
148
+ },
149
+ }
150
+
151
+ /** One published question. */
152
+ const QUESTION_SCHEMA = {
153
+ type: "object",
154
+ additionalProperties: false,
155
+ required: ["key", "group", "type", "title"],
156
+ properties: {
157
+ key: { type: "string", pattern: KEY_PATTERN.source },
158
+ group: { type: "string" },
159
+ type: { type: "string", enum: [...QUESTION_TYPES] },
160
+ title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
161
+ why: { type: "string", maxLength: TEXT_MAX },
162
+ evidence: {
163
+ type: "array",
164
+ maxItems: LIMITS.evidence,
165
+ items: { type: "string", maxLength: TEXT_MAX },
166
+ },
167
+ options: {
168
+ type: "array",
169
+ minItems: LIMITS.optionsMin,
170
+ maxItems: LIMITS.optionsMax,
171
+ items: OPTION_SCHEMA,
172
+ },
173
+ recommendation: {
174
+ anyOf: [
175
+ { type: "string" },
176
+ { type: "array", minItems: 1, items: { type: "string" } },
177
+ ],
178
+ },
179
+ showIf: { type: "array", items: SHOW_IF_SCHEMA },
180
+ proposal: PROPOSAL_SCHEMA,
181
+ },
182
+ }
183
+
184
+ /**
185
+ * JSON Schema of the `codeops_discovery_publish` payload.
186
+ *
187
+ * Every object level rejects unknown keys, so a typo in a field name is a
188
+ * validation error instead of silently ignored content.
189
+ */
190
+ export const DiscoveryPublishSchema = {
191
+ type: "object",
192
+ additionalProperties: false,
193
+ required: ["artifact", "kind", "title", "groups", "questions"],
194
+ properties: {
195
+ artifact: { type: "string", minLength: 1, maxLength: ARTIFACT_MAX },
196
+ kind: { type: "string", enum: [...PLAN_KINDS] },
197
+ title: { type: "string", minLength: 1, maxLength: TITLE_MAX },
198
+ takeover: { type: "boolean" },
199
+ groups: {
200
+ type: "array",
201
+ minItems: 1,
202
+ maxItems: LIMITS.groups,
203
+ items: GROUP_SCHEMA,
204
+ },
205
+ questions: {
206
+ type: "array",
207
+ minItems: 1,
208
+ maxItems: LIMITS.questions,
209
+ items: QUESTION_SCHEMA,
210
+ },
211
+ },
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Small helpers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** True for an object created by `{}` or `JSON.parse`, false for arrays/null. */
219
+ function isPlainObject(value) {
220
+ return value !== null && typeof value === "object" && !Array.isArray(value)
221
+ }
222
+
223
+ /** Trims a value that must be a string, returning "" for any other type. */
224
+ function trimmedString(value) {
225
+ return typeof value === "string" ? value.trim() : ""
226
+ }
227
+
228
+ /** Trims an optional string and reports length violations. */
229
+ function optionalString(value, path, maxLength, fail) {
230
+ if (value === undefined) return undefined
231
+ if (typeof value !== "string") {
232
+ fail(path, "expected a string")
233
+ return undefined
234
+ }
235
+ const trimmed = value.trim()
236
+ if (trimmed.length > maxLength) {
237
+ fail(path, `must be at most ${maxLength} characters`)
238
+ return undefined
239
+ }
240
+ return trimmed.length === 0 ? undefined : trimmed
241
+ }
242
+
243
+ /** Reports fields that the contract does not define. */
244
+ function rejectUnknownKeys(value, allowed, path, fail) {
245
+ for (const key of Object.keys(value)) {
246
+ if (allowed.includes(key)) continue
247
+ fail(path === "" ? `payload.${key}` : `${path}.${key}`, "unknown field")
248
+ }
249
+ }
250
+
251
+ /** Canonical serialization of an answer, used for equality and conflicts. */
252
+ function canonicalAnswer(answer) {
253
+ if (answer === null || answer === undefined) return "null"
254
+ if (Array.isArray(answer.choices)) {
255
+ return JSON.stringify({ choices: [...answer.choices].sort() })
256
+ }
257
+ if (typeof answer.choice === "string") return JSON.stringify({ choice: answer.choice })
258
+ if (typeof answer.text === "string") return JSON.stringify({ text: answer.text })
259
+ if (typeof answer.other === "string") return JSON.stringify({ other: answer.other })
260
+ return JSON.stringify(answer)
261
+ }
262
+
263
+ /** True when two answers carry the same value. */
264
+ function sameAnswer(left, right) {
265
+ return canonicalAnswer(left) === canonicalAnswer(right)
266
+ }
267
+
268
+ /** Option ids selected by a stored answer, ignoring free text. */
269
+ function selectedOptionIds(question, answer) {
270
+ if (!answer) return []
271
+ if (question.type === "multiple") {
272
+ return Array.isArray(answer.choices) ? [...answer.choices] : []
273
+ }
274
+ if (question.type === "single" || question.type === "single-other") {
275
+ return typeof answer.choice === "string" ? [answer.choice] : []
276
+ }
277
+ return []
278
+ }
279
+
280
+ /** Trims a compact answer's string parts without changing its shape. */
281
+ function normalizeCompactAnswer(compact) {
282
+ if (typeof compact === "string") return compact.trim()
283
+ if (Array.isArray(compact)) {
284
+ return compact.map((value) => (typeof value === "string" ? value.trim() : value))
285
+ }
286
+ return compact
287
+ }
288
+
289
+ /** Validates a repository-relative POSIX artifact path. */
290
+ function validateArtifact(artifact) {
291
+ if (artifact.length === 0) return "is required"
292
+ if (artifact.length > ARTIFACT_MAX) return `must be at most ${ARTIFACT_MAX} characters`
293
+ if (artifact.startsWith("/")) return "must be repository-relative"
294
+ if (artifact.includes("\\")) return "must use POSIX separators"
295
+ const segments = artifact.split("/")
296
+ if (segments.some((segment) => segment.length === 0)) return "must not contain empty segments"
297
+ if (segments.includes("..")) return "must not contain a parent segment"
298
+ return null
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Publish validation
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Validate and normalize a publish payload.
307
+ *
308
+ * The function never throws. It collects every structural problem first, then
309
+ * checks cross-references and cycles, and finally returns a normalized copy
310
+ * with trimmed strings and dropped empty optional values.
311
+ *
312
+ * @param {unknown} input - The raw tool input.
313
+ * @returns {{ ok: true, plan: object } | { ok: false, errors: string[] }}
314
+ * The normalized plan, or one error per problem with its field path.
315
+ */
316
+ export function validatePublish(input) {
317
+ const errors = []
318
+ const fail = (path, message) => errors.push(`${path}: ${message}`)
319
+ if (!isPlainObject(input)) return { ok: false, errors: ["payload: expected an object"] }
320
+
321
+ rejectUnknownKeys(
322
+ input,
323
+ ["artifact", "kind", "title", "takeover", "groups", "questions"],
324
+ "",
325
+ fail,
326
+ )
327
+
328
+ const artifact = trimmedString(input.artifact)
329
+ const artifactError = validateArtifact(artifact)
330
+ if (artifactError !== null) fail("artifact", artifactError)
331
+
332
+ if (!PLAN_KINDS.includes(input.kind)) {
333
+ fail("kind", `expected one of ${PLAN_KINDS.join(", ")}`)
334
+ }
335
+
336
+ let title = trimmedString(input.title)
337
+ if (title.length === 0) fail("title", "is required")
338
+ else if (title.length > TITLE_MAX) {
339
+ fail("title", `must be at most ${TITLE_MAX} characters`)
340
+ }
341
+
342
+ if ("takeover" in input && typeof input.takeover !== "boolean") {
343
+ fail("takeover", "expected a boolean")
344
+ }
345
+
346
+ const groups = []
347
+ const seenGroupKeys = new Set()
348
+ if (!Array.isArray(input.groups)) {
349
+ fail("groups", "expected an array")
350
+ } else {
351
+ if (input.groups.length < 1 || input.groups.length > LIMITS.groups) {
352
+ fail("groups", `must hold 1 to ${LIMITS.groups} entries`)
353
+ }
354
+ input.groups.forEach((raw, index) => {
355
+ const path = `groups[${index}]`
356
+ if (!isPlainObject(raw)) {
357
+ fail(path, "expected an object")
358
+ return
359
+ }
360
+ rejectUnknownKeys(raw, ["key", "title", "description", "dependsOn"], path, fail)
361
+ const key = trimmedString(raw.key)
362
+ if (!KEY_PATTERN.test(key)) fail(`${path}.key`, "must be a key of 1 to 40 characters")
363
+ else if (seenGroupKeys.has(key)) fail(`${path}.key`, `duplicate group key "${key}"`)
364
+ else seenGroupKeys.add(key)
365
+ const groupTitle = trimmedString(raw.title)
366
+ if (groupTitle.length === 0) fail(`${path}.title`, "is required")
367
+ else if (groupTitle.length > TITLE_MAX) {
368
+ fail(`${path}.title`, `must be at most ${TITLE_MAX} characters`)
369
+ }
370
+ const description = optionalString(raw.description, `${path}.description`, TEXT_MAX, fail)
371
+ const dependsOn = []
372
+ if (raw.dependsOn !== undefined) {
373
+ if (!Array.isArray(raw.dependsOn)) fail(`${path}.dependsOn`, "expected an array")
374
+ else {
375
+ raw.dependsOn.forEach((value, position) => {
376
+ const target = trimmedString(value)
377
+ if (!KEY_PATTERN.test(target)) {
378
+ fail(`${path}.dependsOn[${position}]`, "expected a group key")
379
+ return
380
+ }
381
+ dependsOn.push(target)
382
+ })
383
+ }
384
+ }
385
+ groups.push({
386
+ key,
387
+ title: groupTitle,
388
+ ...(description === undefined ? {} : { description }),
389
+ dependsOn,
390
+ })
391
+ })
392
+ }
393
+
394
+ const questions = []
395
+ const questionPaths = []
396
+ const questionByKey = new Map()
397
+ if (!Array.isArray(input.questions)) {
398
+ fail("questions", "expected an array")
399
+ } else {
400
+ if (input.questions.length < 1 || input.questions.length > LIMITS.questions) {
401
+ fail("questions", `must hold 1 to ${LIMITS.questions} entries`)
402
+ }
403
+ input.questions.forEach((raw, index) => {
404
+ const path = `questions[${index}]`
405
+ questionPaths[index] = path
406
+ if (!isPlainObject(raw)) {
407
+ fail(path, "expected an object")
408
+ return
409
+ }
410
+ rejectUnknownKeys(
411
+ raw,
412
+ [
413
+ "key",
414
+ "group",
415
+ "type",
416
+ "title",
417
+ "why",
418
+ "evidence",
419
+ "options",
420
+ "recommendation",
421
+ "showIf",
422
+ "proposal",
423
+ ],
424
+ path,
425
+ fail,
426
+ )
427
+ const key = trimmedString(raw.key)
428
+ if (!KEY_PATTERN.test(key)) fail(`${path}.key`, "must be a key of 1 to 40 characters")
429
+ else if (questionByKey.has(key)) fail(`${path}.key`, `duplicate question key "${key}"`)
430
+ const group = trimmedString(raw.group)
431
+ if (group.length === 0) fail(`${path}.group`, "is required")
432
+ const type = raw.type
433
+ if (!QUESTION_TYPES.includes(type)) {
434
+ fail(`${path}.type`, `expected one of ${QUESTION_TYPES.join(", ")}`)
435
+ }
436
+ const questionTitle = trimmedString(raw.title)
437
+ if (questionTitle.length === 0) fail(`${path}.title`, "is required")
438
+ else if (questionTitle.length > TITLE_MAX) {
439
+ fail(`${path}.title`, `must be at most ${TITLE_MAX} characters`)
440
+ }
441
+ const why = optionalString(raw.why, `${path}.why`, TEXT_MAX, fail)
442
+
443
+ let evidence
444
+ if (raw.evidence !== undefined) {
445
+ if (!Array.isArray(raw.evidence)) fail(`${path}.evidence`, "expected an array")
446
+ else {
447
+ if (raw.evidence.length > LIMITS.evidence) {
448
+ fail(`${path}.evidence`, `must hold at most ${LIMITS.evidence} items`)
449
+ }
450
+ evidence = []
451
+ raw.evidence.forEach((value, position) => {
452
+ if (typeof value !== "string") {
453
+ fail(`${path}.evidence[${position}]`, "expected a string")
454
+ return
455
+ }
456
+ const trimmed = value.trim()
457
+ if (trimmed.length > TEXT_MAX) {
458
+ fail(`${path}.evidence[${position}]`, `must be at most ${TEXT_MAX} characters`)
459
+ return
460
+ }
461
+ if (trimmed.length > 0) evidence.push(trimmed)
462
+ })
463
+ }
464
+ }
465
+
466
+ let options
467
+ if (type === "text") {
468
+ if (raw.options !== undefined) fail(`${path}.options`, "a text question has no options")
469
+ } else if (CHOICE_TYPES.includes(type)) {
470
+ if (!Array.isArray(raw.options)) {
471
+ fail(`${path}.options`, "expected an array")
472
+ } else {
473
+ if (
474
+ raw.options.length < LIMITS.optionsMin ||
475
+ raw.options.length > LIMITS.optionsMax
476
+ ) {
477
+ fail(`${path}.options`, `must hold ${LIMITS.optionsMin} to ${LIMITS.optionsMax} options`)
478
+ }
479
+ options = []
480
+ const seenIds = new Set()
481
+ raw.options.forEach((rawOption, position) => {
482
+ const optionPath = `${path}.options[${position}]`
483
+ if (!isPlainObject(rawOption)) {
484
+ fail(optionPath, "expected an object")
485
+ return
486
+ }
487
+ rejectUnknownKeys(rawOption, ["id", "label", "description", "implies"], optionPath, fail)
488
+ const id = trimmedString(rawOption.id)
489
+ if (!KEY_PATTERN.test(id)) {
490
+ fail(`${optionPath}.id`, "must be an option id of 1 to 40 characters")
491
+ } else if (seenIds.has(id)) {
492
+ fail(`${optionPath}.id`, `duplicate option id "${id}"`)
493
+ }
494
+ seenIds.add(id)
495
+ const label = trimmedString(rawOption.label)
496
+ if (label.length === 0) fail(`${optionPath}.label`, "is required")
497
+ else if (label.length > TITLE_MAX) {
498
+ fail(`${optionPath}.label`, `must be at most ${TITLE_MAX} characters`)
499
+ }
500
+ const description = optionalString(
501
+ rawOption.description,
502
+ `${optionPath}.description`,
503
+ TEXT_MAX,
504
+ fail,
505
+ )
506
+ if (rawOption.implies !== undefined && !Array.isArray(rawOption.implies)) {
507
+ fail(`${optionPath}.implies`, "expected an array")
508
+ }
509
+ options.push({
510
+ id,
511
+ label,
512
+ ...(description === undefined ? {} : { description }),
513
+ ...(Array.isArray(rawOption.implies) ? { implies: rawOption.implies } : {}),
514
+ })
515
+ })
516
+ }
517
+ }
518
+
519
+ let recommendation
520
+ if (raw.recommendation !== undefined) {
521
+ if (typeof raw.recommendation === "string") {
522
+ const trimmed = raw.recommendation.trim()
523
+ if (trimmed.length === 0 || trimmed.length > TEXT_MAX) {
524
+ fail(`${path}.recommendation`, "must be a non-empty string")
525
+ } else {
526
+ recommendation = trimmed
527
+ }
528
+ } else if (Array.isArray(raw.recommendation)) {
529
+ if (raw.recommendation.length === 0) {
530
+ fail(`${path}.recommendation`, "must not be empty")
531
+ } else {
532
+ recommendation = []
533
+ raw.recommendation.forEach((value, position) => {
534
+ const trimmed = trimmedString(value)
535
+ if (trimmed.length === 0) {
536
+ fail(`${path}.recommendation[${position}]`, "must not be empty")
537
+ return
538
+ }
539
+ recommendation.push(trimmed)
540
+ })
541
+ }
542
+ } else {
543
+ fail(`${path}.recommendation`, "expected a string or an array of strings")
544
+ }
545
+ }
546
+
547
+ let showIf
548
+ if (raw.showIf !== undefined) {
549
+ if (!Array.isArray(raw.showIf)) fail(`${path}.showIf`, "expected an array")
550
+ else {
551
+ showIf = []
552
+ raw.showIf.forEach((condition, position) => {
553
+ const conditionPath = `${path}.showIf[${position}]`
554
+ if (!isPlainObject(condition)) {
555
+ fail(conditionPath, "expected an object")
556
+ return
557
+ }
558
+ rejectUnknownKeys(condition, ["question", "anyOf"], conditionPath, fail)
559
+ const reference = trimmedString(condition.question)
560
+ if (reference.length === 0) fail(`${conditionPath}.question`, "is required")
561
+ const anyOf = []
562
+ if (!Array.isArray(condition.anyOf) || condition.anyOf.length === 0) {
563
+ fail(`${conditionPath}.anyOf`, "expected a non-empty array of option ids")
564
+ } else {
565
+ condition.anyOf.forEach((value, idPosition) => {
566
+ const id = trimmedString(value)
567
+ if (!KEY_PATTERN.test(id)) {
568
+ fail(`${conditionPath}.anyOf[${idPosition}]`, "expected an option id")
569
+ return
570
+ }
571
+ anyOf.push(id)
572
+ })
573
+ }
574
+ showIf.push({ question: reference, anyOf })
575
+ })
576
+ }
577
+ }
578
+
579
+ const normalized = {
580
+ key,
581
+ group,
582
+ type,
583
+ title: questionTitle,
584
+ ...(why === undefined ? {} : { why }),
585
+ ...(evidence === undefined ? {} : { evidence }),
586
+ ...(options === undefined ? {} : { options }),
587
+ ...(recommendation === undefined ? {} : { recommendation }),
588
+ ...(showIf === undefined ? {} : { showIf }),
589
+ ...(raw.proposal === undefined ? {} : { proposal: raw.proposal }),
590
+ }
591
+ if (key.length > 0 && !questionByKey.has(key)) questionByKey.set(key, normalized)
592
+ questions.push(normalized)
593
+ })
594
+ }
595
+
596
+ // Structural problems make the cross-reference pass meaningless.
597
+ if (errors.length > 0) return { ok: false, errors }
598
+
599
+ const groupKeys = new Set(groups.map((group) => group.key))
600
+ for (const [index, group] of groups.entries()) {
601
+ for (const [position, dependency] of group.dependsOn.entries()) {
602
+ if (!groupKeys.has(dependency)) {
603
+ fail(`groups[${index}].dependsOn[${position}]`, `unknown group "${dependency}"`)
604
+ }
605
+ if (dependency === group.key) {
606
+ fail(`groups[${index}].dependsOn[${position}]`, "a group cannot depend on itself")
607
+ }
608
+ }
609
+ }
610
+
611
+ for (const [index, question] of questions.entries()) {
612
+ const path = questionPaths[index]
613
+ if (!groupKeys.has(question.group)) {
614
+ fail(`${path}.group`, `unknown group "${question.group}"`)
615
+ }
616
+ for (const [position, condition] of (question.showIf ?? []).entries()) {
617
+ const conditionPath = `${path}.showIf[${position}]`
618
+ const target = questionByKey.get(condition.question)
619
+ if (!target) {
620
+ fail(`${conditionPath}.question`, `unknown question "${condition.question}"`)
621
+ continue
622
+ }
623
+ if (target.key === question.key) {
624
+ fail(`${conditionPath}.question`, "a question cannot depend on itself")
625
+ continue
626
+ }
627
+ if (!CHOICE_TYPES.includes(target.type)) {
628
+ fail(`${conditionPath}.question`, "the referenced question must be a choice question")
629
+ continue
630
+ }
631
+ for (const [idPosition, id] of condition.anyOf.entries()) {
632
+ if (!optionIdsOf(target).includes(id)) {
633
+ fail(`${conditionPath}.anyOf[${idPosition}]`, `unknown option "${id}"`)
634
+ }
635
+ }
636
+ }
637
+ for (const [optionPosition, option] of (question.options ?? []).entries()) {
638
+ for (const [implyPosition, implication] of (option.implies ?? []).entries()) {
639
+ const implyPath = `${path}.options[${optionPosition}].implies[${implyPosition}]`
640
+ validateImplication(implication, implyPath, questionByKey, question, fail)
641
+ }
642
+ }
643
+ if (question.proposal !== undefined) {
644
+ validateProposal(question, question.proposal, `${path}.proposal`, fail)
645
+ }
646
+ validateRecommendation(question, `${path}.recommendation`, fail)
647
+ }
648
+
649
+ if (errors.length > 0) return { ok: false, errors }
650
+
651
+ checkAcyclic(
652
+ groups.map((group) => [group.key, group.dependsOn]),
653
+ (cycle) => fail("groups", `dependency cycle ${cycle.join(" -> ")}`),
654
+ )
655
+ checkAcyclic(
656
+ questions
657
+ .filter((question) => (question.showIf ?? []).length > 0)
658
+ .map((question) => [question.key, question.showIf.map((condition) => condition.question)]),
659
+ (cycle) => fail("questions", `showIf cycle ${cycle.join(" -> ")}`),
660
+ )
661
+
662
+ if (errors.length > 0) return { ok: false, errors }
663
+
664
+ const normalizedQuestions = questions.map((question) => ({
665
+ ...question,
666
+ ...(question.options === undefined
667
+ ? {}
668
+ : {
669
+ options: question.options.map((option) => ({
670
+ ...option,
671
+ ...(option.implies === undefined
672
+ ? {}
673
+ : {
674
+ implies: option.implies.map((implication) => ({
675
+ question: implication.question.trim(),
676
+ answer: normalizeCompactAnswer(implication.answer),
677
+ reason: implication.reason.trim(),
678
+ })),
679
+ }),
680
+ })),
681
+ }),
682
+ ...(question.proposal === undefined
683
+ ? {}
684
+ : {
685
+ proposal: {
686
+ answer: normalizeCompactAnswer(question.proposal.answer),
687
+ reason: question.proposal.reason.trim(),
688
+ },
689
+ }),
690
+ }))
691
+
692
+ return {
693
+ ok: true,
694
+ plan: {
695
+ artifact,
696
+ kind: input.kind,
697
+ title,
698
+ groups,
699
+ questions: normalizedQuestions,
700
+ },
701
+ }
702
+ }
703
+
704
+ /** Option ids of a choice question. */
705
+ function optionIdsOf(question) {
706
+ return (question.options ?? []).map((option) => option.id)
707
+ }
708
+
709
+ /** Validate one `implies` entry against the payload's questions. */
710
+ function validateImplication(implication, path, questionByKey, owner, fail) {
711
+ if (!isPlainObject(implication)) {
712
+ fail(path, "expected an object")
713
+ return
714
+ }
715
+ rejectUnknownKeys(implication, ["question", "answer", "reason"], path, fail)
716
+ const reference = trimmedString(implication.question)
717
+ if (reference.length === 0) {
718
+ fail(`${path}.question`, "is required")
719
+ return
720
+ }
721
+ const target = questionByKey.get(reference)
722
+ if (!target) {
723
+ fail(`${path}.question`, `unknown question "${reference}"`)
724
+ return
725
+ }
726
+ if (target.key === owner.key) {
727
+ fail(`${path}.question`, "a question cannot imply itself")
728
+ return
729
+ }
730
+ const reason = trimmedString(implication.reason)
731
+ if (reason.length === 0) fail(`${path}.reason`, "is required")
732
+ else if (reason.length > TEXT_MAX) {
733
+ fail(`${path}.reason`, `must be at most ${TEXT_MAX} characters`)
734
+ }
735
+ const compact = validateCompactAnswer(target, implication.answer)
736
+ if (!compact.ok) fail(`${path}.answer`, compact.error)
737
+ }
738
+
739
+ /** Validate a proposal's answer against its own question. */
740
+ function validateProposal(question, proposal, path, fail) {
741
+ if (!isPlainObject(proposal)) {
742
+ fail(path, "expected an object")
743
+ return
744
+ }
745
+ rejectUnknownKeys(proposal, ["answer", "reason"], path, fail)
746
+ const reason = trimmedString(proposal.reason)
747
+ if (reason.length === 0) fail(`${path}.reason`, "is required")
748
+ else if (reason.length > TEXT_MAX) {
749
+ fail(`${path}.reason`, `must be at most ${TEXT_MAX} characters`)
750
+ }
751
+ const compact = validateCompactAnswer(question, proposal.answer)
752
+ if (!compact.ok) fail(`${path}.answer`, compact.error)
753
+ }
754
+
755
+ /** Validate a recommendation against its question type. */
756
+ function validateRecommendation(question, path, fail) {
757
+ if (question.recommendation === undefined) return
758
+ const ids = optionIdsOf(question)
759
+ if (question.type === "text") {
760
+ if (typeof question.recommendation !== "string" || question.recommendation.length === 0) {
761
+ fail(path, "a text question recommends a non-empty string")
762
+ }
763
+ return
764
+ }
765
+ if (question.type === "multiple") {
766
+ if (!Array.isArray(question.recommendation)) {
767
+ fail(path, "a multiple question recommends an array of option ids")
768
+ return
769
+ }
770
+ for (const id of question.recommendation) {
771
+ if (!ids.includes(id)) fail(path, `unknown option "${id}"`)
772
+ }
773
+ return
774
+ }
775
+ if (typeof question.recommendation !== "string") {
776
+ fail(path, "a single question recommends one option id")
777
+ return
778
+ }
779
+ if (!ids.includes(question.recommendation)) {
780
+ fail(path, `unknown option "${question.recommendation}"`)
781
+ }
782
+ }
783
+
784
+ /** Validate a compact answer (option id, ids, or text) for a question. */
785
+ function validateCompactAnswer(question, compact) {
786
+ const ids = optionIdsOf(question)
787
+ if (question.type === "multiple") {
788
+ if (!Array.isArray(compact) || compact.length === 0) {
789
+ return { ok: false, error: "expected a non-empty array of option ids" }
790
+ }
791
+ for (const value of compact) {
792
+ if (typeof value !== "string" || !ids.includes(value)) {
793
+ return { ok: false, error: `unknown option "${String(value)}"` }
794
+ }
795
+ }
796
+ return { ok: true }
797
+ }
798
+ if (question.type === "text") {
799
+ if (typeof compact !== "string" || compact.trim().length === 0) {
800
+ return { ok: false, error: "expected non-empty text" }
801
+ }
802
+ if (compact.trim().length > TEXT_MAX) {
803
+ return { ok: false, error: `must be at most ${TEXT_MAX} characters` }
804
+ }
805
+ return { ok: true }
806
+ }
807
+ if (typeof compact !== "string" || !ids.includes(compact)) {
808
+ return { ok: false, error: `unknown option "${String(compact)}"` }
809
+ }
810
+ return { ok: true }
811
+ }
812
+
813
+ /** Detect a cycle in a directed graph and report the first one found. */
814
+ function checkAcyclic(edges, onCycle) {
815
+ const state = new Map()
816
+ const stack = []
817
+ let reported = false
818
+
819
+ function visit(node) {
820
+ if (reported) return
821
+ const current = state.get(node) ?? 0
822
+ if (current === 1) {
823
+ const start = stack.indexOf(node)
824
+ onCycle(stack.slice(start).concat(node))
825
+ reported = true
826
+ return
827
+ }
828
+ if (current === 2) return
829
+ state.set(node, 1)
830
+ stack.push(node)
831
+ const targets = edges.find(([source]) => source === node)?.[1] ?? []
832
+ for (const target of targets) visit(target)
833
+ stack.pop()
834
+ state.set(node, 2)
835
+ }
836
+
837
+ for (const [node] of edges) visit(node)
838
+ }
839
+
840
+ // ---------------------------------------------------------------------------
841
+ // Answers and deferrals
842
+ // ---------------------------------------------------------------------------
843
+
844
+ /**
845
+ * Validate one stored answer for its question type.
846
+ *
847
+ * Exactly one of `choice`, `choices`, `text`, or `other` is allowed, except
848
+ * that a `single-other` question accepts `choice` or `other` — never both.
849
+ *
850
+ * @param {object} question - A normalized question.
851
+ * @param {unknown} answer - The candidate stored answer.
852
+ * @returns {{ ok: true } | { ok: false, error: string }}
853
+ */
854
+ export function validateAnswer(question, answer) {
855
+ if (!isPlainObject(answer)) return { ok: false, error: "expected an object" }
856
+ const ids = optionIdsOf(question)
857
+ const fields = Object.keys(answer)
858
+
859
+ if (question.type === "single") {
860
+ if (fields.length !== 1 || !("choice" in answer)) {
861
+ return { ok: false, error: "a single question accepts exactly one choice" }
862
+ }
863
+ if (typeof answer.choice !== "string" || !ids.includes(answer.choice)) {
864
+ return { ok: false, error: `unknown option "${String(answer.choice)}"` }
865
+ }
866
+ return { ok: true }
867
+ }
868
+ if (question.type === "single-other") {
869
+ if (fields.length !== 1 || (!("choice" in answer) && !("other" in answer))) {
870
+ return { ok: false, error: "a single-other question accepts a choice or free text, not both" }
871
+ }
872
+ if ("choice" in answer) {
873
+ if (typeof answer.choice !== "string" || !ids.includes(answer.choice)) {
874
+ return { ok: false, error: `unknown option "${String(answer.choice)}"` }
875
+ }
876
+ return { ok: true }
877
+ }
878
+ const other = trimmedString(answer.other)
879
+ if (other.length === 0) return { ok: false, error: "free text must not be empty" }
880
+ if (other.length > TEXT_MAX) {
881
+ return { ok: false, error: `free text must be at most ${TEXT_MAX} characters` }
882
+ }
883
+ return { ok: true }
884
+ }
885
+ if (question.type === "multiple") {
886
+ if (fields.length !== 1 || !("choices" in answer)) {
887
+ return { ok: false, error: "a multiple question accepts exactly one choices list" }
888
+ }
889
+ if (!Array.isArray(answer.choices) || answer.choices.length === 0) {
890
+ return { ok: false, error: "choose at least one option" }
891
+ }
892
+ const seen = new Set()
893
+ for (const value of answer.choices) {
894
+ if (typeof value !== "string" || !ids.includes(value)) {
895
+ return { ok: false, error: `unknown option "${String(value)}"` }
896
+ }
897
+ if (seen.has(value)) return { ok: false, error: `repeated option "${value}"` }
898
+ seen.add(value)
899
+ }
900
+ return { ok: true }
901
+ }
902
+ if (question.type === "text") {
903
+ if (fields.length !== 1 || !("text" in answer)) {
904
+ return { ok: false, error: "a text question accepts exactly one text value" }
905
+ }
906
+ const value = trimmedString(answer.text)
907
+ if (value.length === 0) return { ok: false, error: "text must not be empty" }
908
+ if (value.length > TEXT_MAX) {
909
+ return { ok: false, error: `text must be at most ${TEXT_MAX} characters` }
910
+ }
911
+ return { ok: true }
912
+ }
913
+ return { ok: false, error: `unsupported question type "${String(question.type)}"` }
914
+ }
915
+
916
+ /**
917
+ * Validate a deferral: decision, owner, and revisit must all be non-empty.
918
+ *
919
+ * @param {unknown} defer - The candidate deferral.
920
+ * @returns {{ ok: true } | { ok: false, error: string }}
921
+ */
922
+ export function validateDefer(defer) {
923
+ if (!isPlainObject(defer)) return { ok: false, error: "expected an object" }
924
+ for (const field of ["decision", "owner", "revisit"]) {
925
+ const value = trimmedString(defer[field])
926
+ if (value.length === 0) return { ok: false, error: `${field} is required` }
927
+ if (value.length > TEXT_MAX) {
928
+ return { ok: false, error: `${field} must be at most ${TEXT_MAX} characters` }
929
+ }
930
+ }
931
+ return { ok: true }
932
+ }
933
+
934
+ /**
935
+ * Convert a compact answer (option id, ids, or text) into the stored shape.
936
+ *
937
+ * @param {object} question - The target question.
938
+ * @param {unknown} compact - A validated compact answer.
939
+ * @returns {object} `{ choice }`, `{ choices }`, or `{ text }`.
940
+ */
941
+ export function toAnswer(question, compact) {
942
+ if (question.type === "multiple") return { choices: [...compact] }
943
+ if (question.type === "text") return { text: String(compact).trim() }
944
+ return { choice: compact }
945
+ }
946
+
947
+ // ---------------------------------------------------------------------------
948
+ // Fingerprint
949
+ // ---------------------------------------------------------------------------
950
+
951
+ /**
952
+ * Canonical content string used to detect a changed question on republish.
953
+ *
954
+ * Two questions with the same fingerprint keep the user's answer untouched.
955
+ * Group membership, visibility rules, and the agent's proposal are excluded so
956
+ * moving or re-revealing a question never invalidates an answer.
957
+ *
958
+ * @param {object} question - A normalized question.
959
+ * @returns {string} A deterministic serialization of the content fields.
960
+ */
961
+ export function questionFingerprint(question) {
962
+ const canonical = {}
963
+ for (const field of FINGERPRINT_FIELDS) {
964
+ if (field === "options") {
965
+ canonical.options = (question.options ?? []).map((option) => ({
966
+ id: option.id,
967
+ label: option.label,
968
+ description: option.description ?? null,
969
+ }))
970
+ continue
971
+ }
972
+ if (field === "evidence") {
973
+ canonical.evidence = [...(question.evidence ?? [])]
974
+ continue
975
+ }
976
+ canonical[field] = question[field] ?? null
977
+ }
978
+ return JSON.stringify(canonical)
979
+ }
980
+
981
+ // ---------------------------------------------------------------------------
982
+ // Rule evaluation
983
+ // ---------------------------------------------------------------------------
984
+
985
+ /**
986
+ * Evaluate visibility, implications, conflicts, and group locks.
987
+ *
988
+ * Visibility reads only the **explicit** answers, so an implied answer can
989
+ * never reveal or hide another question. Implications are computed in one
990
+ * pass from explicit answers on visible questions; an implied answer never
991
+ * triggers further implications.
992
+ *
993
+ * @param {object} plan - A normalized published plan.
994
+ * @param {object} state - The plan state (`{ questions, submittedGroups }`).
995
+ * @returns {{ questions: Map<string, object>, lockedGroups: Set<string> }}
996
+ */
997
+ export function evaluatePlan(plan, state) {
998
+ const questions = plan.questions ?? []
999
+ const stateQuestions = state?.questions ?? {}
1000
+ const submittedGroups = new Set(state?.submittedGroups ?? [])
1001
+ const questionByKey = new Map(questions.map((question) => [question.key, question]))
1002
+
1003
+ const statusOf = (key) => stateQuestions[key]?.status ?? "open"
1004
+ const explicitAnswerOf = (key) => stateQuestions[key]?.answer ?? null
1005
+
1006
+ const visibility = new Map()
1007
+ for (const question of questions) {
1008
+ if (statusOf(question.key) === "retired") {
1009
+ visibility.set(question.key, false)
1010
+ continue
1011
+ }
1012
+ const conditions = question.showIf ?? []
1013
+ if (conditions.length === 0) {
1014
+ visibility.set(question.key, true)
1015
+ continue
1016
+ }
1017
+ const satisfied = conditions.every((condition) => {
1018
+ const target = questionByKey.get(condition.question)
1019
+ if (!target) return false
1020
+ if (statusOf(condition.question) === "retired") return false
1021
+ const answer = explicitAnswerOf(condition.question)
1022
+ if (!answer) return false
1023
+ return selectedOptionIds(target, answer).some((id) => condition.anyOf.includes(id))
1024
+ })
1025
+ visibility.set(question.key, satisfied)
1026
+ }
1027
+
1028
+ const implied = new Map()
1029
+ for (const question of questions) {
1030
+ if (visibility.get(question.key) !== true) continue
1031
+ if (!question.options) continue
1032
+ const answer = explicitAnswerOf(question.key)
1033
+ if (!answer) continue
1034
+ const selected = selectedOptionIds(question, answer)
1035
+ for (const option of question.options) {
1036
+ if (!selected.includes(option.id)) continue
1037
+ for (const implication of option.implies ?? []) {
1038
+ if (visibility.get(implication.question) !== true) continue
1039
+ const target = questionByKey.get(implication.question)
1040
+ if (!target) continue
1041
+ const entries = implied.get(implication.question) ?? []
1042
+ entries.push({
1043
+ from: question.key,
1044
+ reason: implication.reason,
1045
+ answer: toAnswer(target, implication.answer),
1046
+ })
1047
+ implied.set(implication.question, entries)
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ const evaluated = new Map()
1053
+ for (const question of questions) {
1054
+ const visible = visibility.get(question.key) === true
1055
+ const explicit = explicitAnswerOf(question.key)
1056
+ const entries = visible ? implied.get(question.key) ?? [] : []
1057
+ const distinct = new Map()
1058
+ for (const entry of entries) distinct.set(canonicalAnswer(entry.answer), entry.answer)
1059
+ const conflict =
1060
+ distinct.size > 1
1061
+ ? entries.map((entry) => ({ from: entry.from, answer: entry.answer }))
1062
+ : []
1063
+ const impliedAnswer = distinct.size === 1 ? [...distinct.values()][0] : null
1064
+
1065
+ let effective = null
1066
+ let source = null
1067
+ let overridesImplied = false
1068
+ if (visible) {
1069
+ if (explicit !== null) {
1070
+ effective = explicit
1071
+ source = "user"
1072
+ overridesImplied = impliedAnswer !== null && !sameAnswer(explicit, impliedAnswer)
1073
+ } else if (impliedAnswer !== null) {
1074
+ effective = impliedAnswer
1075
+ source = "implied"
1076
+ }
1077
+ }
1078
+
1079
+ evaluated.set(question.key, {
1080
+ visible,
1081
+ effective,
1082
+ source,
1083
+ impliedBy: entries.map((entry) => entry.from),
1084
+ impliedReasons: entries.map((entry) => entry.reason),
1085
+ overridesImplied,
1086
+ conflict,
1087
+ })
1088
+ }
1089
+
1090
+ const lockedGroups = new Set()
1091
+ for (const group of plan.groups ?? []) {
1092
+ if ((group.dependsOn ?? []).some((dependency) => !submittedGroups.has(dependency))) {
1093
+ lockedGroups.add(group.key)
1094
+ }
1095
+ }
1096
+
1097
+ return { questions: evaluated, lockedGroups }
1098
+ }
1099
+
1100
+ // ---------------------------------------------------------------------------
1101
+ // Readiness and counts
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ /**
1105
+ * Whether a group can be submitted, and what is missing when it cannot.
1106
+ *
1107
+ * A group is ready when it is unlocked, has at least one pending visible
1108
+ * `open` question, every pending question is answered, validly deferred, or
1109
+ * marked "needs discussion", and at least one of them is answered or deferred
1110
+ * so the submission always carries something for the agent.
1111
+ *
1112
+ * @param {object} plan - A normalized published plan.
1113
+ * @param {object} state - The plan state.
1114
+ * @param {object} evaluation - The result of {@link evaluatePlan}.
1115
+ * @param {string} groupKey - The group to inspect.
1116
+ * @returns {{ ready: boolean, locked: boolean, missing: Array<{key: string, reason: string}> }}
1117
+ */
1118
+ export function groupReadiness(plan, state, evaluation, groupKey) {
1119
+ const stateQuestions = state?.questions ?? {}
1120
+ const locked = evaluation.lockedGroups.has(groupKey)
1121
+ const missing = []
1122
+ const pending = (plan.questions ?? []).filter(
1123
+ (question) =>
1124
+ question.group === groupKey &&
1125
+ evaluation.questions.get(question.key)?.visible === true &&
1126
+ (stateQuestions[question.key]?.status ?? "open") === "open",
1127
+ )
1128
+ let carriesSubmission = false
1129
+
1130
+ for (const question of pending) {
1131
+ const questionState = stateQuestions[question.key] ?? {}
1132
+ const entry = evaluation.questions.get(question.key)
1133
+ if (questionState.changed === true) {
1134
+ missing.push({ key: question.key, reason: "changed" })
1135
+ continue
1136
+ }
1137
+ if (entry.conflict.length > 0 && questionState.answer == null) {
1138
+ missing.push({ key: question.key, reason: "conflict" })
1139
+ continue
1140
+ }
1141
+ if (entry.effective !== null) {
1142
+ carriesSubmission = true
1143
+ continue
1144
+ }
1145
+ if (questionState.defer && validateDefer(questionState.defer).ok) {
1146
+ carriesSubmission = true
1147
+ continue
1148
+ }
1149
+ if (questionState.needsDiscussion === true) continue
1150
+ if (question.proposal !== undefined && questionState.proposalDismissed !== true) {
1151
+ missing.push({ key: question.key, reason: "proposal" })
1152
+ continue
1153
+ }
1154
+ missing.push({ key: question.key, reason: "unanswered" })
1155
+ }
1156
+
1157
+ if (missing.length === 0 && (!carriesSubmission || pending.length === 0)) {
1158
+ missing.push({ key: groupKey, reason: "nothing-to-submit" })
1159
+ }
1160
+
1161
+ return {
1162
+ ready: !locked && pending.length > 0 && missing.length === 0,
1163
+ locked,
1164
+ missing,
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Counts used by list views. A question lands in exactly one bucket.
1170
+ *
1171
+ * Hidden and retired questions are excluded from every count. `open` counts
1172
+ * visible questions still waiting for an answer, a deferral, or a discussion
1173
+ * marker.
1174
+ *
1175
+ * @param {object} plan - A normalized published plan.
1176
+ * @param {object} state - The plan state.
1177
+ * @param {object} evaluation - The result of {@link evaluatePlan}.
1178
+ * @returns {{ open: number, answered: number, deferred: number, discussion: number,
1179
+ * submitted: number, recorded: number }}
1180
+ */
1181
+ export function planCounts(plan, state, evaluation) {
1182
+ const counts = {
1183
+ open: 0,
1184
+ answered: 0,
1185
+ deferred: 0,
1186
+ discussion: 0,
1187
+ submitted: 0,
1188
+ recorded: 0,
1189
+ }
1190
+ const stateQuestions = state?.questions ?? {}
1191
+
1192
+ for (const question of plan.questions ?? []) {
1193
+ if (evaluation.questions.get(question.key)?.visible !== true) continue
1194
+ const questionState = stateQuestions[question.key] ?? {}
1195
+ const status = questionState.status ?? "open"
1196
+ if (status === "submitted") {
1197
+ counts.submitted += 1
1198
+ continue
1199
+ }
1200
+ if (status === "recorded") {
1201
+ counts.recorded += 1
1202
+ continue
1203
+ }
1204
+ if (status !== "open") continue
1205
+ const entry = evaluation.questions.get(question.key)
1206
+ if (entry.effective !== null) counts.answered += 1
1207
+ else if (questionState.needsDiscussion === true) counts.discussion += 1
1208
+ else if (questionState.defer && validateDefer(questionState.defer).ok) counts.deferred += 1
1209
+ else counts.open += 1
1210
+ }
1211
+
1212
+ return counts
1213
+ }