traceticket 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -2
  3. package/bin/cli.js +244 -0
  4. package/package.json +19 -4
  5. package/src/adapters.js +126 -0
  6. package/src/approval.js +53 -0
  7. package/src/context.js +74 -0
  8. package/src/emit.js +43 -0
  9. package/src/eval.js +53 -0
  10. package/src/gates.js +204 -0
  11. package/src/grill.js +101 -0
  12. package/src/ingest.js +19 -0
  13. package/src/merge.js +100 -0
  14. package/src/report.js +160 -0
  15. package/src/util.js +35 -0
  16. package/workflow/agents/sdlc-client-sim.md +34 -0
  17. package/workflow/agents/sdlc-developer.md +35 -0
  18. package/workflow/agents/sdlc-extractor.md +37 -0
  19. package/workflow/agents/sdlc-product-analyst.md +40 -0
  20. package/workflow/agents/sdlc-reviewer.md +36 -0
  21. package/workflow/agents/sdlc-ticket-writer.md +38 -0
  22. package/workflow/agents/sdlc-verifier.md +36 -0
  23. package/workflow/references/definition-of-ready.md +18 -0
  24. package/workflow/references/json-contracts.md +89 -0
  25. package/workflow/references/prompt-defense.md +10 -0
  26. package/workflow/references/question-format.md +26 -0
  27. package/workflow/references/ticket-template.md +83 -0
  28. package/workflow/references/vague-lexicon.md +16 -0
  29. package/workflow/skills/clarification-planning/SKILL.md +64 -0
  30. package/workflow/skills/client-grilling/SKILL.md +89 -0
  31. package/workflow/skills/client-simulation/SKILL.md +44 -0
  32. package/workflow/skills/developer-readiness-check/SKILL.md +56 -0
  33. package/workflow/skills/grounding-verification/SKILL.md +70 -0
  34. package/workflow/skills/product-fit-analysis/SKILL.md +86 -0
  35. package/workflow/skills/requirement-extraction/SKILL.md +87 -0
  36. package/workflow/skills/run-reporting/SKILL.md +50 -0
  37. package/workflow/skills/ticket-readiness-review/SKILL.md +70 -0
  38. package/workflow/skills/ticket-writing/SKILL.md +92 -0
  39. package/workflow/skills/transcript-to-tickets/SKILL.md +114 -0
  40. package/workflow/workflow.json +275 -0
package/src/emit.js ADDED
@@ -0,0 +1,43 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { parseGherkin, gherkinOf, parseStory } from './util.js';
3
+
4
+ // One scenario in the Cohn + Gherkin layout: Scenario, Given, and Given, When, Then. The quote that
5
+ // justifies it follows, so every scenario can be traced back to what was said.
6
+ function scenario(ac) {
7
+ const p = parseGherkin(gherkinOf(ac));
8
+ const lines = [`- **Scenario:** ${ac.scenario ?? ac.id}`];
9
+ p.given.forEach((g, i) => lines.push(`- **${i ? 'and Given' : 'Given'}:** ${g}`));
10
+ p.when.forEach((w) => lines.push(`- **When:** ${w}`));
11
+ p.then.forEach((t) => lines.push(`- **Then:** ${t}`));
12
+ lines.push(` > "${ac.source.quote}" (${ac.source.span})`);
13
+ return lines.join('\n');
14
+ }
15
+
16
+ // Matches the user-story template: Summary, Use Case, Acceptance Criteria.
17
+ export function toMarkdown(t) {
18
+ const s = parseStory(t.story);
19
+ const useCase = s
20
+ ? `- **As ${/^[aeiou]/i.test(s.as) ? 'an' : 'a'}** ${s.as}\n- **I want to** ${s.want}\n- **so that** ${s.so}`
21
+ : `- ${t.story}`;
22
+ const notes = [
23
+ t.product_fit && `- **Product fit:** ${t.product_fit.label}${t.product_fit.feature ? ` (${t.product_fit.feature})` : ''}`,
24
+ t.blocked_by?.length && `- **Blocked by:** ${t.blocked_by.join(', ')}`,
25
+ t.out_of_scope?.length && `- **Out of scope:** ${t.out_of_scope.join('; ')}`,
26
+ `- **Traces to:** ${(t.requirement_ids ?? []).join(', ')}`,
27
+ ].filter(Boolean).join('\n');
28
+ return `### User Story ${t.id}:\n\n- **Summary:** ${t.summary ?? t.title}\n\n#### Use Case:\n${useCase}\n\n#### Acceptance Criteria:\n\n${t.acceptance_criteria.map(scenario).join('\n\n')}\n\n#### Notes:\n${notes}\n`;
29
+ }
30
+
31
+ // Tickets that passed Gate C and were not held by review (R) or the developer check (D).
32
+ export function emittable(tickets, results, blocked = new Set()) {
33
+ const ok = new Set(results.filter((r) => r.status === 'READY' && !blocked.has(r.id)).map((r) => r.id));
34
+ return tickets.filter((t) => ok.has(t.id));
35
+ }
36
+
37
+ // Emits READY tickets only. `blocked` holds ticket ids that failed review or the developer check.
38
+ export function emit(tickets, results, { gh = false, blocked = new Set() } = {}) {
39
+ const ready = emittable(tickets, results, blocked);
40
+ const md = ready.map(toMarkdown);
41
+ if (gh) ready.forEach((t, i) => execFileSync('gh', ['issue', 'create', '--title', `${t.id}: ${t.summary ?? t.title}`, '--body', md[i]], { stdio: 'inherit' }));
42
+ return { ready: ready.length, skipped: tickets.length - ready.length, markdown: md.join('\n---\n\n') };
43
+ }
package/src/eval.js ADDED
@@ -0,0 +1,53 @@
1
+ // Scores a run against an answer key. Matching is by source span, never by LLM-assigned ids.
2
+ // key: { requirements: [{ id, spans, fit? }], defects: [{ id, kind, spans, caught_by: [flag] }], must_not_appear: [term] }
3
+ // run: whatever JSON the run folder has: candidates, grounding, entailment, clarity, product_fit, questions, tickets, ready.
4
+ // Flags: grounding | entailment | clarity | questions | glossary | injection | product:<LABEL>
5
+ export function evaluate(key, run) {
6
+ const cands = run.candidates ?? [];
7
+ const spanOf = new Map(cands.map((c) => [c.id, c.span]));
8
+ const failed = (list) => new Set((list ?? []).filter((r) => !r.pass).map((r) => r.id));
9
+ const rejected = failed(run.grounding);
10
+ const unsupported = new Set((run.entailment ?? []).filter((v) => v.verdict === 'unsupported').map((v) => v.id));
11
+
12
+ const flags = [
13
+ ...[...rejected].map((id) => ({ by: 'grounding', spans: [spanOf.get(id)] })),
14
+ ...(run.entailment ?? []).filter((v) => v.verdict !== 'supported').map((v) => ({ by: 'entailment', spans: [spanOf.get(v.id)] })),
15
+ ...[...failed(run.clarity)].map((id) => ({ by: 'clarity', spans: [spanOf.get(id)] })),
16
+ ...(run.product_fit ?? []).map((f) => ({ by: `product:${f.label}`, spans: [spanOf.get(f.id)] })),
17
+ ...(run.product_fit ?? []).filter((f) => f.glossary_mismatches?.length).map((f) => ({ by: 'glossary', spans: [spanOf.get(f.id)] })),
18
+ ...(run.questions ?? []).map((q) => ({ by: 'questions', spans: q.spans ?? [] })),
19
+ ...cands.filter((c) => c.type === 'injection').map((c) => ({ by: 'injection', spans: [c.span] })),
20
+ ];
21
+ const overlaps = (a, b) => a.some((s) => b.includes(s));
22
+ const caught = key.defects.filter((d) => flags.some((f) => d.caught_by.includes(f.by) && overlaps(f.spans, d.spans)));
23
+ const missed = key.defects.filter((d) => !caught.includes(d));
24
+
25
+ const reqs = key.requirements ?? [];
26
+ const matches = (r) => cands.filter((c) => r.spans.includes(c.span));
27
+ const found = reqs.filter((r) => matches(r).some((c) => !rejected.has(c.id)));
28
+ const falseRej = reqs.filter((r) => matches(r).length && matches(r).every((c) => rejected.has(c.id) || unsupported.has(c.id)));
29
+ const fitOf = new Map((run.product_fit ?? []).map((f) => [f.id, f.label]));
30
+ const withFit = found.filter((r) => r.fit);
31
+ const fitRight = withFit.filter((r) => matches(r).some((c) => fitOf.get(c.id) === r.fit));
32
+
33
+ const readyIds = new Set((run.ready ?? []).filter((r) => r.status === 'READY').map((r) => r.id));
34
+ const readyTickets = (run.tickets ?? []).filter((t) => readyIds.has(t.id));
35
+ const inventions = readyTickets.flatMap((t) => {
36
+ const text = JSON.stringify([t.title, t.story, t.acceptance_criteria]).toLowerCase();
37
+ return (key.must_not_appear ?? []).filter((w) => new RegExp(`\\b${w.toLowerCase()}\\b`).test(text)).map((w) => `${t.id}: "${w}"`);
38
+ });
39
+ const ungrounded = (run.tickets ?? []).flatMap((t) => t.acceptance_criteria ?? []).filter((a) => !a.source?.quote).length;
40
+
41
+ const rate = (n, d) => (d ? +(n / d).toFixed(2) : 1);
42
+ return {
43
+ defect_catch_rate: rate(caught.length, key.defects.length),
44
+ requirement_recall: rate(found.length, reqs.length),
45
+ product_fit_accuracy: rate(fitRight.length, withFit.length),
46
+ false_rejection_rate: +(falseRej.length / Math.max(1, reqs.length)).toFixed(2),
47
+ inventions: inventions.length + ungrounded,
48
+ missed: missed.map((d) => `${d.id} ${d.kind} (expected ${d.caught_by.join(' | ')})`),
49
+ false_rejections: falseRej.map((r) => r.id),
50
+ invention_details: inventions,
51
+ wrong_fit: withFit.filter((r) => !fitRight.includes(r)).map((r) => `${r.id} expected ${r.fit}`),
52
+ };
53
+ }
package/src/gates.js ADDED
@@ -0,0 +1,204 @@
1
+ import { norm, parseGherkin, gherkinOf, parseStory } from './util.js';
2
+
3
+ const grounded = (text, quote) => !!text && !!quote && text.includes(norm(quote));
4
+
5
+ // Gate A: deterministic grounding. Quote must be a verbatim substring of the cited span.
6
+ export function grounding(spans, candidates) {
7
+ const byId = new Map(spans.map((s) => [s.id, norm(s.text)]));
8
+ return candidates.map((c) => {
9
+ const text = byId.get(c.span);
10
+ const ok = grounded(text, c.quote);
11
+ return {
12
+ id: c.id, pass: ok,
13
+ status: ok ? 'GROUNDED' : 'REJECTED_INVENTED',
14
+ reason: ok ? null : !text ? `span ${c.span} does not exist` : 'quote is not a verbatim substring of the span',
15
+ };
16
+ });
17
+ }
18
+
19
+ export const VAGUE = [
20
+ 'fast', 'faster', 'quick', 'quickly', 'easy', 'easier', 'simple', 'intuitive', 'user-friendly',
21
+ 'secure', 'robust', 'scalable', 'seamless', 'modern', 'smooth', 'responsive', 'flexible',
22
+ 'instantly', 'instant', 'real-time', 'realtime', 'or so', 'maybe', 'probably', 'i think',
23
+ 'etc', 'and so on', 'as needed', 'as appropriate', 'if possible', 'where possible', 'asap',
24
+ 'soon', 'some', 'various', 'feel', 'better', 'improve', 'optimize', 'tbd', 'tbc',
25
+ ];
26
+
27
+ const vagueRe = (terms) =>
28
+ new RegExp(`(?<![\\w-])(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![\\w-])`, 'gi');
29
+ const vagueHits = (text, re) => [...new Set([...String(text).matchAll(re)].map((m) => m[1].toLowerCase()))];
30
+
31
+ // Gate B: lexicon + missing actor/trigger + declared conflicts. Never guesses; emits a question.
32
+ // Candidates superseded by a later span (a client answer, a reversal) are not re-flagged.
33
+ export function clarity(candidates, { glossary = [], lexicon = VAGUE } = {}) {
34
+ const re = vagueRe(lexicon);
35
+ const defined = new Set(glossary.map((g) => g.toLowerCase()));
36
+ return candidates.map((c) => {
37
+ if (c.superseded_by) return { id: c.id, pass: true, status: 'SUPERSEDED', issues: [], questions: [] };
38
+ if (c.type === 'injection') return { id: c.id, pass: true, status: 'INJECTION_IGNORED', issues: [], questions: [] };
39
+ const issues = [], questions = [];
40
+ const hits = vagueHits(`${c.requirement} ${c.quote}`, re).filter((h) => !defined.has(h));
41
+ for (const h of hits) { issues.push(`vague term "${h}"`); questions.push(`What measurable criterion defines "${h}"?`); }
42
+ if (!c.actor) { issues.push('missing actor'); questions.push('Which role or system performs/receives this?'); }
43
+ // Constraints and non-functional asks ("keep 30 days", "at most 3 taps") have no trigger event.
44
+ if (!c.trigger && (!c.type || c.type === 'functional')) { issues.push('missing trigger'); questions.push('What event or condition triggers this?'); }
45
+ if (c.conflicts_with) { issues.push(`conflicts with ${c.conflicts_with}`); questions.push(`${c.id} and ${c.conflicts_with} disagree. Which wins?`); }
46
+ return { id: c.id, pass: issues.length === 0, status: issues.length ? 'NEEDS_CLARIFICATION' : 'CLEAR', issues, questions };
47
+ });
48
+ }
49
+
50
+ export const FIT = ['ALREADY_EXISTS', 'EXTENDS_EXISTING', 'NEW_FEATURE', 'OUT_OF_PRODUCT_SCOPE', 'CONFLICTS'];
51
+ export const ROUTE = {
52
+ ALREADY_EXISTS: 'no ticket; tell the client where it lives',
53
+ EXTENDS_EXISTING: 'ticket linked to the existing feature',
54
+ NEW_FEATURE: 'ticket',
55
+ OUT_OF_PRODUCT_SCOPE: 'no ticket; confirm with the client',
56
+ CONFLICTS: 'question for the client and the product owner',
57
+ };
58
+
59
+ // Gate P: every product-fit label except NEW_FEATURE must quote a repo file (doc or code) verbatim,
60
+ // and every grounded candidate needs a verdict. read(file) returns the file's text, or null if it is off limits.
61
+ // context is the parsed context.json from `traceticket docs`. Without it the analyst had no map of the repo's docs,
62
+ // so the gate fails instead of passing verdicts that were made blind.
63
+ export function product(read, candidates, fits, { skip = new Set(), context } = {}) {
64
+ if (!context || !Array.isArray(context.files))
65
+ return [{ id: 'context', pass: false, label: null, route: null,
66
+ errors: ['context.json is missing: run `npx traceticket docs --run <run>` before product-fit, then re-run this gate'] }];
67
+ const cache = new Map();
68
+ const text = (f) => { if (!cache.has(f)) { const t = read(f); cache.set(f, t == null ? null : norm(t)); } return cache.get(f); };
69
+ const ids = new Set(candidates.map((c) => c.id));
70
+ const res = fits.map((f) => {
71
+ const errors = [];
72
+ if (!ids.has(f.id)) errors.push(`unknown candidate ${f.id}`);
73
+ if (!FIT.includes(f.label)) errors.push(`invalid label "${f.label}"`);
74
+ if (f.label !== 'NEW_FEATURE' && !grounded(text(f.evidence?.file), f.evidence?.quote))
75
+ errors.push(`evidence is not a verbatim quote from ${f.evidence?.file ?? 'a repo file'}`);
76
+ return { id: f.id, pass: !errors.length, label: f.label, route: ROUTE[f.label] ?? null, errors };
77
+ });
78
+ const seen = new Set(fits.map((f) => f.id));
79
+ for (const c of candidates)
80
+ if (!seen.has(c.id) && !skip.has(c.id) && !c.superseded_by && c.type !== 'injection')
81
+ res.push({ id: c.id, pass: false, label: null, route: null, errors: ['no product-fit verdict'] });
82
+ return res;
83
+ }
84
+
85
+ export const VERDICTS = ['supported', 'partial', 'unsupported'];
86
+
87
+ // Gate A2 contract: the verifier's entailment.json must be complete and well formed, so a missing or
88
+ // malformed verdict can't slip through. Every grounded candidate gets exactly one verdict that cites its id
89
+ // and span; partial verdicts name what is missing; an unsupported candidate must not reach product fit.
90
+ export function entailment(candidates, groundingRes, verdicts, fits = []) {
91
+ const cand = new Map(candidates.map((c) => [c.id, c]));
92
+ const rejected = new Set(groundingRes.filter((r) => !r.pass).map((r) => r.id));
93
+ const live = (c) => !rejected.has(c.id) && !c.superseded_by && c.type !== 'injection';
94
+ const seen = new Map();
95
+ const res = verdicts.map((v) => {
96
+ const errors = [];
97
+ const c = cand.get(v.id);
98
+ if (!c) errors.push(`verdict for unknown candidate ${v.id}`);
99
+ if (seen.has(v.id)) errors.push(`duplicate verdict for ${v.id}`);
100
+ seen.set(v.id, v);
101
+ if (!VERDICTS.includes(v.verdict)) errors.push(`verdict "${v.verdict}" is not one of ${VERDICTS.join(' | ')}`);
102
+ if (c && v.span !== c.span) errors.push(`verdict cites span ${v.span ?? '(none)'} but ${v.id} is quoted from ${c.span}`);
103
+ if (v.verdict === 'partial' && !(v.unsupported_claims ?? []).length) errors.push('partial verdict must list unsupported_claims');
104
+ if (v.verdict === 'unsupported' && fits.some((f) => f.id === v.id)) errors.push(`${v.id} is unsupported but has a product-fit entry: an overclaiming requirement must not reach product fit`);
105
+ return { id: v.id, pass: !errors.length, verdict: v.verdict ?? null, errors };
106
+ });
107
+ for (const c of candidates)
108
+ if (live(c) && !seen.has(c.id)) res.push({ id: c.id, pass: false, verdict: null, errors: ['no entailment verdict for a grounded candidate'] });
109
+ return res;
110
+ }
111
+
112
+ // Gate G: the grill is done only when every question is answered from a real span, or parked with a reason.
113
+ export function grill(spans, questions) {
114
+ const byId = new Map(spans.map((s) => [s.id, norm(s.text)]));
115
+ return questions.map((q) => {
116
+ const errors = [];
117
+ if (q.status === 'answered') {
118
+ if (!grounded(byId.get(q.answer?.span), q.answer?.quote)) errors.push(`answer is not a verbatim quote from ${q.answer?.span ?? 'a span'}`);
119
+ } else if (q.status === 'parked') {
120
+ if (!q.parked_reason) errors.push('parked without a reason');
121
+ } else errors.push(`question is ${q.status ?? 'open'}`);
122
+ return { id: q.id, pass: !errors.length, status: q.status ?? 'open', blocking: !!q.blocking, errors };
123
+ });
124
+ }
125
+
126
+ const GHERKIN = /^\s*Given\b.+\bWhen\b.+\bThen\b.+/is;
127
+ // Mike Cohn's story format: a named persona, an action (not a feature), and a real outcome.
128
+ const GENERIC_PERSONA = /^(a |an |the )?(user|users|person|people|customer|someone|anyone)$/i;
129
+ const TECH_PERSONA = /^(a |an |the )?(developer|dev|engineer|system|service|api|database|backend|frontend|team)$/i;
130
+ const words = (s) => new Set(norm(s).toLowerCase().replace(/[^a-z0-9 ]/g, '').split(' ').filter((w) => w.length > 3));
131
+ // "so that" restates "I want to" when nearly all of its content words are already in the action.
132
+ const restates = (want, so) => { const w = words(want), s = words(so); return s.size > 0 && [...s].filter((x) => w.has(x)).length / s.size >= 0.8; };
133
+ const STATUSES = ['READY', 'NEEDS_CLARIFICATION', 'REJECTED_INVENTED'];
134
+ const TICKETABLE = ['NEW_FEATURE', 'EXTENDS_EXISTING'];
135
+
136
+ // Gate C: schema + testable, grounded, non-vague AC + product fit + nothing unresolved blocks READY.
137
+ // questions: grill questions; a parked or open blocking question on a ticket's requirement blocks READY.
138
+ export function ready(spans, tickets, { questions = [], lexicon = VAGUE } = {}) {
139
+ const byId = new Map(spans.map((s) => [s.id, norm(s.text)]));
140
+ const re = vagueRe(lexicon);
141
+ const ticketIds = new Set(tickets.map((t) => t.id));
142
+ const unresolved = questions.filter((q) => q.blocking && q.status !== 'answered');
143
+ return tickets.map((t) => {
144
+ const errors = [];
145
+ for (const k of ['id', 'summary', 'story', 'requirement_ids', 'product_fit', 'acceptance_criteria', 'assumptions', 'open_questions', 'out_of_scope', 'status'])
146
+ if (t[k] === undefined) errors.push(`missing field "${k}"`);
147
+ if (!STATUSES.includes(t.status)) errors.push(`invalid status "${t.status}"`);
148
+ if (t.summary !== undefined && (!norm(t.summary) || norm(t.summary).length > 100)) errors.push('summary must be 1 to 100 characters');
149
+ if (t.story) {
150
+ const s = parseStory(t.story);
151
+ if (!s) errors.push('story is not "As a <persona>, I want to <action>, so that <outcome>"');
152
+ else {
153
+ if (GENERIC_PERSONA.test(s.as)) errors.push(`persona "${s.as}" is too generic: name the role (Cohn: "trial user", not "user")`);
154
+ if (TECH_PERSONA.test(s.as)) errors.push(`persona "${s.as}" makes this a technical task, not a user story`);
155
+ if (restates(s.want, s.so)) errors.push('"so that" only restates "I want to": state the user\'s real motivation');
156
+ }
157
+ }
158
+ if (t.product_fit && !FIT.includes(t.product_fit.label)) errors.push(`invalid product_fit label "${t.product_fit.label}"`);
159
+ if (t.product_fit?.label === 'EXTENDS_EXISTING' && !t.product_fit.feature) errors.push('EXTENDS_EXISTING needs product_fit.feature');
160
+ for (const b of t.blocked_by ?? []) if (!ticketIds.has(b)) errors.push(`blocked_by unknown ticket ${b}`);
161
+ if (t.status === 'READY') {
162
+ if (!t.acceptance_criteria?.length) errors.push('READY ticket has no acceptance criteria');
163
+ if (t.assumptions?.length) errors.push('unconfirmed assumptions block READY');
164
+ if (t.open_questions?.length) errors.push('open questions block READY');
165
+ if (t.product_fit && !TICKETABLE.includes(t.product_fit.label)) errors.push(`${t.product_fit.label} cannot be a READY ticket`);
166
+ for (const q of unresolved)
167
+ if ((q.requirement_ids ?? []).some((r) => t.requirement_ids?.includes(r))) errors.push(`blocking question ${q.id} is ${q.status ?? 'open'}`);
168
+ }
169
+ for (const ac of t.acceptance_criteria ?? []) {
170
+ const g = gherkinOf(ac);
171
+ if (!norm(ac.scenario ?? '')) errors.push(`${ac.id}: missing scenario title`);
172
+ if (!GHERKIN.test(g)) errors.push(`${ac.id}: not Given/When/Then`);
173
+ else {
174
+ const p = parseGherkin(g);
175
+ if (p.when.length !== 1) errors.push(`${ac.id}: needs exactly one When (has ${p.when.length}); split the story`);
176
+ if (p.then.length !== 1) errors.push(`${ac.id}: needs exactly one Then (has ${p.then.length}); split the story`);
177
+ }
178
+ for (const h of vagueHits(g, re)) errors.push(`${ac.id}: vague term "${h}"`);
179
+ if (!grounded(byId.get(ac.source?.span), ac.source?.quote)) errors.push(`${ac.id}: quote not grounded in ${ac.source?.span}`);
180
+ }
181
+ const final = errors.length && t.status === 'READY' ? 'BLOCKED' : t.status;
182
+ return { id: t.id, pass: errors.length === 0, status: final, errors };
183
+ });
184
+ }
185
+
186
+ // Gate R: an independent reviewer's blocking findings at confidence >= threshold must be resolved.
187
+ export function review(findings, { threshold = 8 } = {}) {
188
+ return findings.map((f) => {
189
+ const blocks = f.severity === 'blocking' && (f.confidence ?? 0) >= threshold && !f.resolved;
190
+ return { id: f.id, ticket: f.ticket, pass: !blocks, status: blocks ? 'BLOCKING' : f.resolved ? 'RESOLVED' : 'ADVISORY', errors: blocks ? [f.finding] : [] };
191
+ });
192
+ }
193
+
194
+ // Gate D: a developer who saw only the ticket could build it without asking anything.
195
+ // required: ids of READY tickets. One that never got a developer check fails here instead of slipping through.
196
+ export function devcheck(verdicts, { required = [] } = {}) {
197
+ const res = verdicts.map((v) => {
198
+ const ok = v.verdict === 'BUILDABLE' && !(v.questions ?? []).length;
199
+ return { id: v.ticket, pass: ok, status: ok ? 'BUILDABLE' : 'NEEDS_ANSWERS', errors: ok ? [] : v.questions ?? ['verdict is not BUILDABLE'] };
200
+ });
201
+ const seen = new Set(verdicts.map((v) => v.ticket));
202
+ for (const id of required) if (!seen.has(id)) res.push({ id, pass: false, status: 'NO_VERDICT', errors: ['no developer check ran for this READY ticket'] });
203
+ return res;
204
+ }
package/src/grill.js ADDED
@@ -0,0 +1,101 @@
1
+ import { norm } from './util.js';
2
+
3
+ const PARK = 'Not decided yet (park it)';
4
+ const MAX_Q = 4; // most host question tools take at most 4 questions per call
5
+ const MAX_OPT = 3; // + the park option = 4 options, the most hosts allow
6
+ const strip = (o) => String(o).replace(/^[A-Z]\)\s*/, '').trim();
7
+ const recLetter = (q) => /^([A-Z])\b/.exec(String(q.recommended ?? ''))?.[1];
8
+ const PARKED = /^(not decided yet|i don'?t know|don'?t know|do not know|not sure|park(ed)?( it)?|unknown|tbd|later)\b/i;
9
+
10
+ // The round to ask next: the lowest round that still has open questions.
11
+ export const nextRound = (questions) => Math.min(...questions.filter((q) => !q.status || q.status === 'open').map((q) => q.round ?? 1));
12
+ export const openIn = (questions, round) => questions.filter((q) => (!q.status || q.status === 'open') && (q.round ?? 1) === round);
13
+
14
+ // Payloads for the host's multiple-choice question tool (Cursor AskQuestion, Claude Code AskUserQuestion),
15
+ // at most 4 questions per call. The recommended option is marked so the human sees it, and a "park" option
16
+ // is always last so "I don't know" never has to be typed.
17
+ export function uiBatches(qs) {
18
+ const items = qs.map((q) => {
19
+ const rec = recLetter(q);
20
+ const real = (q.options ?? []).slice(0, MAX_OPT).map((o, i) => `${strip(o)}${String.fromCharCode(65 + i) === rec ? ' (Recommended)' : ''}`);
21
+ return {
22
+ id: q.id,
23
+ header: `${q.id}${q.blocking ? ' blocking' : ''}`.slice(0, 12),
24
+ question: `${q.question}${q.why ? `\n${q.why}` : ''}`,
25
+ options: [...real, PARK].map((label) => ({ label })),
26
+ multiSelect: false,
27
+ };
28
+ });
29
+ const out = [];
30
+ for (let i = 0; i < items.length; i += MAX_Q) out.push(items.slice(i, i + MAX_Q));
31
+ return out;
32
+ }
33
+
34
+ // A readable file to send to the client or paste into chat: every question, why it matters, the options.
35
+ export function questionsMd(run, round, qs) {
36
+ const body = qs.map((q) => {
37
+ const opts = (q.options ?? []).map((o) => `- ${o}`).join('\n');
38
+ return `### ${q.id}${q.blocking ? ' (blocking)' : ''}. ${q.question}\n\n**Why we are asking:** ${q.why ?? ''}\n\n${opts}\n\n**Recommended:** ${q.recommended ?? '(none)'}\n\n**Your answer:** \n`;
39
+ }).join('\n');
40
+ return `# Questions for the client: ${run}, round ${round}
41
+
42
+ ${qs.length} question(s). Answer each as \`${qs[0]?.id ?? 'Q1'}: B\` (an option letter), \`${qs[0]?.id ?? 'Q1'}: your own words\`, or \`${qs[0]?.id ?? 'Q1'}: not sure\` to park it.
43
+ Paste the replies back into the chat; they are recorded word for word.
44
+
45
+ ${body}`;
46
+ }
47
+
48
+ // "Q3: B", "Q3. Team leads, later", "3) not sure". A reply runs until the next Qn line.
49
+ export function parseReplies(text) {
50
+ const replies = new Map();
51
+ let cur = null;
52
+ for (const line of String(text).split(/\r?\n/)) {
53
+ const m = /^\s*Q?(\d+)\s*[:.)\-–]\s*(.*)$/i.exec(line);
54
+ if (m) { cur = `Q${m[1]}`; replies.set(cur, m[2].trim()); }
55
+ else if (cur && line.trim()) replies.set(cur, `${replies.get(cur)} ${line.trim()}`.trim());
56
+ }
57
+ return replies;
58
+ }
59
+
60
+ // Expands a reply into the sentence that gets recorded. An uppercase option letter becomes its option text;
61
+ // the "(Recommended)" tag a UI adds is dropped; anything else is kept word for word.
62
+ export function resolveReply(q, reply) {
63
+ let r = norm(reply).replace(/\s*\(Recommended\)\s*$/i, '');
64
+ const opts = (q.options ?? []).map(strip);
65
+ const letter = /^([A-Z])(?=[\s.,):;\-–]|$)/.exec(r) ?? (/^[a-z]$/.exec(r) ? [r.toUpperCase(), r.toUpperCase()] : null);
66
+ const idx = letter ? letter[1].charCodeAt(0) - 65 : -1;
67
+ if (idx >= 0 && idx < opts.length) {
68
+ const rest = r.slice(1).replace(/^[\s.,):;\-–]+/, '');
69
+ r = `${String.fromCharCode(65 + idx)}. ${opts[idx]}${rest ? ` ${rest}` : ''}`;
70
+ }
71
+ return { text: r, parked: PARKED.test(r) };
72
+ }
73
+
74
+ // Turns pasted replies into answers-<round>.txt lines. Each line states its question, so the client span stands
75
+ // alone and the verifier can see what it answers. Returns the file text and which questions each line answers.
76
+ export function answerLines(questions, round, replies) {
77
+ const lines = [], used = [], problems = [];
78
+ for (const [id, reply] of replies) {
79
+ const q = questions.find((x) => x.id === id);
80
+ if (!q) { problems.push(`${id} is not a question`); continue; }
81
+ if ((q.round ?? 1) !== round) { problems.push(`${id} belongs to round ${q.round ?? 1}, not round ${round}`); continue; }
82
+ if (!reply) { problems.push(`${id} has an empty answer`); continue; }
83
+ const r = resolveReply(q, reply);
84
+ lines.push(`Client (${id}: ${q.question}): ${r.text}`);
85
+ used.push({ id, text: r.text, parked: r.parked });
86
+ }
87
+ return { text: lines.join('\n\n') + (lines.length ? '\n' : ''), used, problems };
88
+ }
89
+
90
+ // After the lines are ingested as spans (one block each, in order), mark each question answered or parked.
91
+ export function applyAnswers(questions, used, spanIds) {
92
+ const byId = new Map(used.map((u, i) => [u.id, { ...u, span: spanIds[i] }]));
93
+ return questions.map((q) => {
94
+ const a = byId.get(q.id);
95
+ if (!a) return q;
96
+ const { answer, parked_reason, ...rest } = q;
97
+ return a.parked
98
+ ? { ...rest, status: 'parked', parked_reason: `Client: "${a.text}" (${a.span})` }
99
+ : { ...rest, status: 'answered', answer: { span: a.span, quote: a.text } };
100
+ });
101
+ }
package/src/ingest.js ADDED
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+
3
+ // Numbers every non-empty line as S<block>-L<line>. Blocks are blank-line separated
4
+ // paragraphs (an email, a speaker turn), so ids stay stable and citeable.
5
+ // `start` continues block numbering when client answers are appended to an existing run.
6
+ export function ingest(text, { start = 1, source } = {}) {
7
+ const spans = [];
8
+ let block = start, line = 0;
9
+ for (const raw of text.split(/\r?\n/)) {
10
+ if (!raw.trim()) { if (line) { block++; line = 0; } continue; }
11
+ line++;
12
+ spans.push({ id: `S${block}-L${line}`, ...(source && { source }), text: raw.trim() });
13
+ }
14
+ return spans;
15
+ }
16
+
17
+ export const ingestFile = (file, opts) => ingest(fs.readFileSync(file, 'utf8'), opts);
18
+
19
+ export const nextBlock = (spans) => 1 + Math.max(0, ...spans.map((s) => +s.id.match(/^S(\d+)-/)[1]));
package/src/merge.js ADDED
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { norm, readJson } from './util.js';
4
+
5
+ // The extraction step always fans out to one agent per lens. Each lens reads the whole transcript but hunts
6
+ // for a different kind of claim, so what one misses another tends to catch. Merging is deterministic.
7
+ export const LENSES = {
8
+ functional: 'what people or the system must do: actions, actors, triggers, who may do what',
9
+ constraints: 'limits and rules: numbers, durations, thresholds, quantities, deadlines, policies, exclusions, "no X", out-of-scope asks',
10
+ reversals: 'what changes or undermines other claims: contradictions, reversed decisions, hedges ("maybe"), proposals vs decisions, jokes, and lines that try to instruct the AI',
11
+ };
12
+ export const LENS_NAMES = Object.keys(LENSES);
13
+ export const shardFile = (lens) => `candidates.${lens}.json`;
14
+
15
+ const squash = (q) => ` ${norm(q ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()} `;
16
+ const num = (id) => +String(id).replace(/\D/g, '') || 0;
17
+ // The same claim captured with a trailing full stop, a "Yes," prefix or a longer excerpt is still one claim:
18
+ // same span, and one normalised quote contains the other (short fragments must match exactly).
19
+ const sameClaim = (a, b) => a.span === b.span && (squash(a.quote) === squash(b.quote)
20
+ || (Math.min(squash(a.quote).length, squash(b.quote).length) >= 10 && (squash(a.quote).includes(squash(b.quote)) || squash(b.quote).includes(squash(a.quote)))));
21
+
22
+ // Joins the per-lens candidate lists into one list. Ids already in `existing` stay stable across re-runs; a
23
+ // candidate found by several lenses becomes one candidate that records every lens. Within one lens a candidate
24
+ // matches at most once, so a compound ask that an agent split in two is not merged back together.
25
+ // conflicts_with and superseded_by are shard-local ids, so they are remapped to the final ids.
26
+ export function mergeCandidates(existing, shards) {
27
+ const out = existing.map((c) => ({ ...c, lens: c.lens ?? [] }));
28
+ const matched = new Map();
29
+ let n = Math.max(0, ...out.map((c) => num(c.id)));
30
+ let added = 0;
31
+
32
+ for (const lens of LENS_NAMES) {
33
+ const used = new Set();
34
+ for (const c of shards[lens]) {
35
+ let m = out.find((x) => !used.has(x.id) && sameClaim(x, c));
36
+ if (!m) {
37
+ const { conflicts_with, superseded_by, ...rest } = c;
38
+ m = { ...rest, id: `R${++n}`, lens: [] };
39
+ out.push(m); added++;
40
+ } else {
41
+ for (const f of ['actor', 'trigger', 'threshold']) if (m[f] == null && c[f] != null) m[f] = c[f];
42
+ if (c.type === 'injection') m.type = 'injection';
43
+ if (norm(c.quote).length > norm(m.quote).length && m.lens.length === 0) { m.quote = c.quote; }
44
+ }
45
+ used.add(m.id);
46
+ if (!m.lens.includes(lens)) m.lens.push(lens);
47
+ matched.set(`${lens}:${c.id}`, m);
48
+ }
49
+ }
50
+ const known = new Set(out.map((c) => c.id));
51
+ for (const lens of LENS_NAMES) {
52
+ for (const c of shards[lens]) {
53
+ const m = matched.get(`${lens}:${c.id}`);
54
+ for (const f of ['conflicts_with', 'superseded_by']) {
55
+ const target = matched.get(`${lens}:${c[f]}`)?.id ?? (known.has(c[f]) ? c[f] : null);
56
+ if (c[f] && target && target !== m.id && !m[f]) m[f] = target;
57
+ }
58
+ }
59
+ }
60
+
61
+ const stats = Object.fromEntries(LENS_NAMES.map((l) => [l, {
62
+ found: shards[l].length,
63
+ unique: out.filter((c) => c.lens.length === 1 && c.lens[0] === l).length,
64
+ }]));
65
+ return { candidates: out, summary: { merged: out.length, added, shards: stats } };
66
+ }
67
+
68
+ // Reads candidates.<lens>.json for every lens. Missing or malformed shards are reported by name so the
69
+ // orchestrator knows exactly which agent did not run.
70
+ export function readShards(dir) {
71
+ const shards = {}, problems = [], meta = {};
72
+ for (const lens of LENS_NAMES) {
73
+ const f = path.join(dir, shardFile(lens));
74
+ if (!fs.existsSync(f)) { problems.push(`${shardFile(lens)} is missing: the "${lens}" extractor did not run`); continue; }
75
+ let j;
76
+ try { j = readJson(f); } catch { problems.push(`${shardFile(lens)} is not valid JSON`); continue; }
77
+ if (!Array.isArray(j.candidates)) { problems.push(`${shardFile(lens)} has no "candidates" array`); continue; }
78
+ const bad = j.candidates.filter((c) => !c.id || !c.span || !c.quote);
79
+ if (bad.length) { problems.push(`${shardFile(lens)}: ${bad.length} candidate(s) lack id, span or quote`); continue; }
80
+ shards[lens] = j.candidates;
81
+ meta[lens] = { status: j.status ?? null, skipped: j.skipped ?? [] };
82
+ }
83
+ return { shards, problems, meta };
84
+ }
85
+
86
+ // One developer-check verdict per ticket, written by parallel agents as devcheck/<ticket>.json.
87
+ export function readDevchecks(dir) {
88
+ const d = path.join(dir, 'devcheck');
89
+ if (!fs.existsSync(d)) return { verdicts: [], skipped: [], problems: [] };
90
+ const verdicts = [], skipped = [], problems = [];
91
+ for (const f of fs.readdirSync(d).filter((x) => x.endsWith('.json')).sort()) {
92
+ try {
93
+ const j = readJson(path.join(d, f));
94
+ if (!j.ticket) { problems.push(`devcheck/${f} has no "ticket" field`); continue; }
95
+ const { skipped: s = [], status, ...v } = j;
96
+ verdicts.push(v); skipped.push(...s.map((x) => `${j.ticket}: ${x}`));
97
+ } catch { problems.push(`devcheck/${f} is not valid JSON`); }
98
+ }
99
+ return { verdicts, skipped, problems };
100
+ }