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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TraceTicket contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,93 @@
1
- # Temporary Holding Version
1
+ # TraceTicket
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **From messy client call to verified ticket, with receipts.**
4
+
5
+ Turn a client call transcript into tickets a developer can build without asking anything.
6
+ **The LLM proposes; gates dispose.** Every claim traces to a verbatim quote, every request is checked against the product, the client is grilled on anything vague, parallel agents review before a ticket ships, and a human signs off.
7
+
8
+ ## Quick start
9
+
10
+ ```
11
+ npx traceticket init # run once in your repo; auto-detects Claude Code / Cursor / others
12
+ npx traceticket init --target all
13
+ ```
14
+
15
+ Restart your IDE, then run:
16
+
17
+ ```
18
+ /call-to-tickets path/to/transcript.txt
19
+ ```
20
+
21
+ | You type | What happens |
22
+ |---|---|
23
+ | `npx traceticket init` | Installs the skills, agents and commands into your repo |
24
+ | `/call-to-tickets <transcript>` | The whole workflow, from transcript to approved tickets |
25
+ | `/grill-client <run>` | Restart the multiple-choice questions on a run |
26
+ | `/review-tickets <run>` | Re-run the independent review |
27
+ | `/run-report <run>` | Rebuild the report |
28
+
29
+ The package installs the command `traceticket` (the older name `sdlc-kit` still works). Everything it installs calls `npx traceticket`.
30
+
31
+ **No setup for product context.** `traceticket docs` finds the repo's own PRDs, specs, ADRs, glossaries and READMEs (skipping `node_modules`, build output and agent instruction files), and the product analyst reads them and searches the code. Evidence can quote a doc line or a code line; Gate P checks the quote is really in that file. When the docs and the code disagree, the code wins and the drift is reported. Pass `--product <dir>` only to narrow it to one folder.
32
+
33
+ ## The workflow
34
+
35
+ ```
36
+ W1 Intake ingest → extractor → Gate A grounding → verifier (Gate A2)
37
+ W2 Product docs (find PRDs, specs, ADRs) → product analyst (docs + code) → Gate P evidence
38
+ W3 Grill Gate B clarity → question planner → grill ⇄ client → Gate G
39
+ └ answers become spans, W1-W2 re-run on them
40
+ W4 Tickets ticket writer → Gate C ready
41
+ W5 Review reviewer → Gate R → developer (one ticket only) → Gate D ── questions → W3
42
+ W6 Report emit → report → eval
43
+ ```
44
+
45
+ | Gate | Checks | By |
46
+ |---|---|---|
47
+ | A | quote is a verbatim substring of its span | script |
48
+ | A2 | requirement claims no more than its quote | `sdlc-verifier` agent, fresh context |
49
+ | P | every product-fit label quotes the product docs verbatim | script |
50
+ | B | vague words, missing actor/trigger, conflicts | script |
51
+ | G | every question answered from a client span, or parked with a reason | script |
52
+ | C | schema, Given/When/Then, grounded and non-vague AC, product fit, nothing open | script |
53
+ | R | no unresolved blocking review finding at confidence ≥ 8 | `sdlc-reviewer` agent + script |
54
+ | D | a developer who sees only the ticket has no questions | `sdlc-developer` agent + script |
55
+
56
+ ## What gets installed
57
+
58
+ | Path | What |
59
+ |---|---|
60
+ | `.sdlc/skills/*/SKILL.md` | 11 skills, written to the [agent-skills anatomy](https://github.com/addyosmani/agent-skills/blob/main/docs/skill-anatomy.md): Overview, When to Use, Workflow, Rationalizations, Red Flags, Verification |
61
+ | `.sdlc/agents/*.md` | 7 agents in [ECC](https://github.com/affaan-m/everything-claude-code) format, each opening with a Prompt Defense Baseline |
62
+ | `.sdlc/references/` | Definition of Ready, JSON contracts, ticket template, question format, vague lexicon, prompt defense |
63
+ | `.claude/skills`, `.claude/agents`, `.claude/commands` | Claude Code: `/call-to-tickets`, `/grill-client`, `/review-tickets`, `/run-report` |
64
+ | `.cursor/rules/sdlc.mdc`, `.cursor/skills/`, `.cursor/agents/`, `.cursor/commands/` | Cursor: the same four commands |
65
+ | `AGENTS.md`, `GEMINI.md`, `.github/copilot-instructions.md`, ... | Codex, Gemini, Copilot, Windsurf, Cline, opencode |
66
+
67
+ The grill follows Matt Pocock's [grilling / grill-with-docs](https://github.com/mattpocock/skills) pattern (rounds, recommended answers, facts looked up not asked, glossary updated as terms resolve). Stop points, completion statuses and the confidence-8 review bar come from [gstack](https://github.com/garrytan/gstack). The "does it need to exist? does it exist? extend? smallest slice?" ladder and "say what you skipped" rule come from [Ponytail](https://github.com/DietrichGebert/ponytail).
68
+
69
+ ## CLI
70
+
71
+ Every command prints JSON. Must-pass gates exit 1 on failure, so agents and CI use the same checks.
72
+
73
+ ```
74
+ traceticket ingest <file> --run r1 numbered immutable spans (S1-L1 ...)
75
+ traceticket ingest answers-1.txt --run r1 --append client answers become quotable spans
76
+ traceticket gate grounding|product|clarity|grill|ready|review|devcheck --run r1 [--product dir]
77
+ traceticket emit --run r1 [--gh] tickets.md for tickets that passed C, R and D
78
+ traceticket report --run r1 REPORT.md built from run JSON only
79
+ traceticket eval --run r1 --key answer_key.json catch rate, recall, product-fit accuracy, inventions
80
+ traceticket status --run r1
81
+ ```
82
+
83
+ ## Demo fixture
84
+
85
+ `examples/shiftdesk/`: a made-up scheduling product (`product/`: features, glossary, an ADR), a client call with 14 planted defects (contradictions, a decision reversal, vague thresholds, scope creep, a product conflict, an already-built feature, glossary aliases and a prompt injection), a scripted client (`client_answers.md`) and an answer key. `golden-run/` is a full run produced by the agents, replayed through every gate in CI.
86
+
87
+ ## Make your own workflow
88
+
89
+ Edit `workflow/workflow.json` (steps with `kind: tool | llm | human`, `skill`, `agent`, `gate`, `in`, `out`) and add skills under `workflow/skills/`. Every adapter regenerates from it. New host? Add one entry to `ADAPTERS` in `src/adapters.js`.
90
+
91
+ ## Develop
92
+
93
+ `npm test` (Node 18+, zero dependencies). The tests also check that every skill and agent keeps the required sections.
package/bin/cli.js ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { runDir, readJson, writeJson } from '../src/util.js';
5
+ import { ingestFile, nextBlock } from '../src/ingest.js';
6
+ import { grounding, entailment, clarity, product, grill, ready, review, devcheck } from '../src/gates.js';
7
+ import { emit, emittable, toMarkdown } from '../src/emit.js';
8
+ import { approvalRequest, checkApproval } from '../src/approval.js';
9
+ import { evaluate } from '../src/eval.js';
10
+ import { loadRun, report, blockedTickets } from '../src/report.js';
11
+ import { install, detect, ALL, workflowDir } from '../src/adapters.js';
12
+ import { discover, repoReader } from '../src/context.js';
13
+ import { mergeCandidates, readShards, readDevchecks, LENS_NAMES } from '../src/merge.js';
14
+ import { nextRound, openIn, uiBatches, questionsMd, parseReplies, answerLines, applyAnswers } from '../src/grill.js';
15
+
16
+ const HELP = `TraceTicket: from messy client call to verified ticket, with receipts
17
+
18
+ npx traceticket init [--target a,b|all] [--dry-run] install into this repo (auto-detects IDE/agent)
19
+ traceticket ingest <file> --run <name> number the source into spans.json
20
+ traceticket ingest <answers> --run <name> --append add client answers as new spans (source: client)
21
+ traceticket merge candidates --run <name> join the 3 parallel extractors' candidates.<lens>.json (fails if any lens did not run)
22
+ traceticket merge devcheck --run <name> join the parallel developer checks devcheck/<ticket>.json (fails if a READY ticket has none)
23
+ traceticket gate grounding --run <name> Gate A verbatim quote check (needs merge.json: extraction must have fanned out)
24
+ traceticket docs --run <name> [--product dir] find PRDs, specs, ADRs, glossaries, READMEs in the repo -> context.json
25
+ traceticket gate product --run <name> [--product dir] Gate P product-fit evidence quotes a repo file verbatim (default: repo root)
26
+ traceticket gate entailment --run <name> Gate A2 verifier output is complete, cites id and span, no overclaim reaches product fit
27
+ traceticket gate clarity --run <name> [--glossary a,b] Gate B vague / missing actor, trigger / conflicts
28
+ traceticket gate grill --run <name> Gate G every question answered from a span, or parked
29
+ traceticket gate ready --run <name> Gate C schema, Gherkin, grounding, product fit, nothing open
30
+ traceticket gate review --run <name> [--threshold 8] Gate R no unresolved blocking review finding
31
+ traceticket gate devcheck --run <name> Gate D developer could build it without asking
32
+ traceticket questions --run <name> [--round N] write questions-round-N.md and print payloads for the host's multiple-choice UI
33
+ traceticket answers --run <name> [--file f] record pasted replies (Q3: B ...) from stdin or a file as client spans and update questions.json
34
+ traceticket render --run <name> [--ticket T-001] print ticket Markdown to read (files nothing, needs no approval)
35
+ traceticket approval --run <name> write approval.md for a human to sign (APPROVED by <name>)
36
+ traceticket emit --run <name> [--gh] tickets.md for tickets that passed C, R, D and a human approval (--gh files issues)
37
+ traceticket report --run <name> REPORT.md built from the run JSON
38
+ traceticket eval --run <name> --key <answer_key.json> score the run against an answer key
39
+ traceticket status --run <name> show which steps have produced output
40
+
41
+ targets: ${ALL.join(', ')}
42
+ Every command prints JSON. Must-pass gates (P, G, C, R, D) exit 1 on failure; filter gates (A, B) exit 1 when they flag anything.`;
43
+
44
+ function parse(argv) {
45
+ const pos = [], flags = {};
46
+ for (let i = 0; i < argv.length; i++) {
47
+ if (argv[i].startsWith('--')) { const k = argv[i].slice(2); flags[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true; }
48
+ else pos.push(argv[i]);
49
+ }
50
+ return { pos, flags };
51
+ }
52
+ const out = (o) => console.log(JSON.stringify(o, null, 2));
53
+ const need = (f, k) => { if (!f[k] || f[k] === true) { console.error(`missing --${k}`); process.exit(2); } return f[k]; };
54
+ const list = (dir, file, field) => readJson(path.join(dir, file))[field];
55
+ const readJsonIf = (p) => (fs.existsSync(p) ? readJson(p) : null);
56
+ // READY tickets that review did not already hold: each needs a developer check.
57
+ function devcheckRequired(dir) {
58
+ const ready = readJsonIf(path.join(dir, 'ready.json')) ?? [];
59
+ const held = new Set((readJsonIf(path.join(dir, 'review_check.json')) ?? []).filter((r) => !r.pass).map((r) => r.ticket));
60
+ return ready.filter((r) => r.status === 'READY' && !held.has(r.id)).map((r) => r.id);
61
+ }
62
+
63
+ // Evidence paths are relative to --product when given, otherwise to the repo root (docs and code alike).
64
+ function productRoot(flags) {
65
+ const dir = path.resolve(flags.product && flags.product !== true ? flags.product : process.env.SDLC_ROOT || '.');
66
+ if (!fs.existsSync(dir)) throw new Error(`product context not found at ${dir}`);
67
+ return dir;
68
+ }
69
+
70
+ const GATE_OUT = { grounding: 'grounding.json', entailment: 'entailment_check.json', product: 'product_check.json', clarity: 'clarity.json', grill: 'grill.json',
71
+ ready: 'ready.json', review: 'review_check.json', devcheck: 'devcheck_check.json' };
72
+
73
+ function runGate(sub, dir, flags) {
74
+ const spans = () => list(dir, 'spans.json', 'spans');
75
+ if (sub === 'grounding') {
76
+ if (!fs.existsSync(path.join(dir, 'merge.json')))
77
+ return [{ id: 'extraction', pass: false, status: 'NOT_FANNED_OUT', reason: 'merge.json is missing: extraction must run all three lens extractors in parallel, then `traceticket merge candidates --run <run>`' }];
78
+ return grounding(spans(), list(dir, 'candidates.json', 'candidates'));
79
+ }
80
+ if (sub === 'entailment') {
81
+ const run = loadRun(dir);
82
+ if (!run.entailment) throw new Error('entailment.json not found: the verifier has not run');
83
+ return entailment(run.candidates, run.grounding ?? [], run.entailment, run.product_fit ?? []);
84
+ }
85
+ if (sub === 'clarity') return clarity(list(dir, 'candidates.json', 'candidates'), { glossary: flags.glossary ? String(flags.glossary).split(',') : [] });
86
+ if (sub === 'product') {
87
+ const run = loadRun(dir);
88
+ const skip = new Set([...(run.grounding ?? []).filter((r) => !r.pass).map((r) => r.id),
89
+ ...(run.entailment ?? []).filter((v) => v.verdict === 'unsupported').map((v) => v.id)]);
90
+ return product(repoReader(productRoot(flags)), run.candidates, run.product_fit ?? [], { skip, context: readJsonIf(path.join(dir, 'context.json')) });
91
+ }
92
+ if (sub === 'grill') return grill(spans(), list(dir, 'questions.json', 'questions'));
93
+ if (sub === 'ready') {
94
+ const q = path.join(dir, 'questions.json');
95
+ return ready(spans(), list(dir, 'tickets.json', 'tickets'), { questions: fs.existsSync(q) ? readJson(q).questions : [] });
96
+ }
97
+ if (sub === 'review') return review(list(dir, 'review.json', 'findings'), { threshold: flags.threshold ? +flags.threshold : 8 });
98
+ if (sub === 'devcheck') return devcheck(list(dir, 'devcheck.json', 'verdicts'), { required: devcheckRequired(dir) });
99
+ throw new Error(`gate must be one of: ${Object.keys(GATE_OUT).join(' | ')}`);
100
+ }
101
+
102
+ const { pos, flags } = parse(process.argv.slice(2));
103
+ const [cmd, sub] = pos;
104
+ try {
105
+ if (!cmd || cmd === 'help' || flags.help) { console.log(HELP); }
106
+ else if (cmd === 'init') {
107
+ const targets = !flags.target ? detect() : flags.target === 'all' ? ALL : String(flags.target).split(',');
108
+ out({ targets, installed: install(targets, process.cwd(), { dryRun: !!flags['dry-run'] }), next: 'Restart your IDE, then run: /call-to-tickets <transcript file>' });
109
+ }
110
+ else if (cmd === 'ingest') {
111
+ const file = path.join(runDir(need(flags, 'run')), 'spans.json');
112
+ if (flags.append) {
113
+ const cur = readJson(file);
114
+ const added = ingestFile(sub, { start: nextBlock(cur.spans), source: flags.source && flags.source !== true ? flags.source : 'client' });
115
+ writeJson(file, { ...cur, spans: [...cur.spans, ...added] });
116
+ out({ added: added.length, ids: added.map((s) => s.id), total: cur.spans.length + added.length });
117
+ } else {
118
+ const spans = ingestFile(sub, { source: 'input' });
119
+ writeJson(file, { source: path.basename(sub), spans });
120
+ out({ spans: spans.length });
121
+ }
122
+ }
123
+ else if (cmd === 'docs') {
124
+ const ctx = discover(productRoot(flags));
125
+ writeJson(path.join(runDir(need(flags, 'run')), 'context.json'), ctx);
126
+ out({ counts: ctx.counts, truncated: ctx.truncated, top: ctx.files.filter((f) => f.kind !== 'doc').slice(0, 25).map((f) => `${f.kind}: ${f.path}`) });
127
+ }
128
+ else if (cmd === 'gate') {
129
+ const dir = runDir(need(flags, 'run'));
130
+ const res = runGate(sub, dir, flags);
131
+ writeJson(path.join(dir, GATE_OUT[sub]), res);
132
+ out(res);
133
+ if (res.some((r) => !r.pass)) process.exitCode = 1;
134
+ }
135
+ else if (cmd === 'questions') {
136
+ const dir = runDir(need(flags, 'run'));
137
+ const questions = readJson(`${dir}/questions.json`).questions;
138
+ const round = flags.round && flags.round !== true ? +flags.round : questions.some((q) => !q.status || q.status === 'open') ? nextRound(questions) : null;
139
+ const qs = round ? openIn(questions, round) : [];
140
+ if (!qs.length) out({ status: 'NO_OPEN_QUESTIONS' });
141
+ else {
142
+ const file = `${dir}/questions-round-${round}.md`;
143
+ fs.writeFileSync(file, questionsMd(flags.run, round, qs));
144
+ out({ round, count: qs.length, file, batches: uiBatches(qs),
145
+ how: 'Ask each batch with the host multiple-choice tool (Cursor AskQuestion, Claude Code AskUserQuestion), then STOP. Record the replies with `traceticket answers`. The .md file can also be sent to the client as is.' });
146
+ }
147
+ }
148
+ else if (cmd === 'answers') {
149
+ const dir = runDir(need(flags, 'run'));
150
+ const qf = `${dir}/questions.json`, sf = `${dir}/spans.json`;
151
+ const qj = readJson(qf);
152
+ const text = flags.file && flags.file !== true ? fs.readFileSync(flags.file, 'utf8') : fs.readFileSync(0, 'utf8');
153
+ const replies = parseReplies(text);
154
+ if (!replies.size) { console.error('error: no "Q3: answer" lines found in the pasted replies'); process.exit(2); }
155
+ const round = flags.round && flags.round !== true ? +flags.round : Math.min(...[...replies.keys()].map((id) => qj.questions.find((q) => q.id === id)?.round ?? 1));
156
+ const { text: lines, used, problems } = answerLines(qj.questions, round, replies);
157
+ if (problems.length) { console.error(`error: ${problems.join('; ')}`); process.exit(2); }
158
+ let n = 1; while (fs.existsSync(`${dir}/answers-${n}.txt`)) n++;
159
+ const file = `${dir}/answers-${n}.txt`;
160
+ fs.writeFileSync(file, lines);
161
+ const cur = readJson(sf);
162
+ const added = ingestFile(file, { start: nextBlock(cur.spans), source: 'client' });
163
+ writeJson(sf, { ...cur, spans: [...cur.spans, ...added] });
164
+ const updated = applyAnswers(qj.questions, used, added.map((s) => s.id));
165
+ writeJson(qf, { ...qj, questions: updated });
166
+ const open = updated.filter((q) => !q.status || q.status === 'open');
167
+ out({ file, recorded: used.map((u, i) => ({ id: u.id, status: u.parked ? 'parked' : 'answered', span: added[i].id })), still_open: open.map((q) => q.id),
168
+ next: open.length ? 'ask the next round with `traceticket questions`' : 'no open questions: re-run extract (parallel), product fit and clarity on the new spans, then `traceticket gate grill`' });
169
+ }
170
+ else if (cmd === 'render') {
171
+ const dir = runDir(need(flags, 'run'));
172
+ const tickets = readJson(`${dir}/tickets.json`).tickets;
173
+ const held = new Set((readJsonIf(path.join(dir, 'review_check.json')) ?? []).filter((r) => !r.pass).map((r) => r.ticket));
174
+ const ready = new Set((readJsonIf(path.join(dir, 'ready.json')) ?? []).filter((r) => r.status === 'READY').map((r) => r.id));
175
+ const pick = flags.ticket && flags.ticket !== true
176
+ ? tickets.filter((t) => t.id === flags.ticket)
177
+ : tickets.filter((t) => ready.has(t.id) && !held.has(t.id));
178
+ if (!pick.length) throw new Error(flags.ticket ? `no ticket ${flags.ticket}` : 'no READY ticket to render');
179
+ console.log(pick.map(toMarkdown).join('\n---\n\n'));
180
+ }
181
+ else if (cmd === 'merge') {
182
+ const dir = runDir(need(flags, 'run'));
183
+ if (sub === 'candidates') {
184
+ const { shards, problems, meta } = readShards(dir);
185
+ if (problems.length) { console.error(`error: extraction did not fan out. ${problems.join('; ')}`); process.exit(1); }
186
+ const { candidates, summary } = mergeCandidates(readJsonIf(path.join(dir, 'candidates.json'))?.candidates ?? [], shards);
187
+ const skipped = LENS_NAMES.flatMap((l) => meta[l].skipped.map((s) => `${l}: ${s}`));
188
+ writeJson(path.join(dir, 'candidates.json'), { candidates, status: 'DONE', skipped });
189
+ writeJson(path.join(dir, 'merge.json'), { ...summary, lenses: LENS_NAMES, shardStatus: Object.fromEntries(LENS_NAMES.map((l) => [l, meta[l].status])) });
190
+ out(summary);
191
+ } else if (sub === 'devcheck') {
192
+ const { verdicts, skipped, problems } = readDevchecks(dir);
193
+ const missing = devcheckRequired(dir).filter((id) => !verdicts.some((v) => v.ticket === id));
194
+ if (problems.length || missing.length) {
195
+ console.error(`error: developer checks did not all run. ${[...problems, ...missing.map((id) => `devcheck/${id}.json is missing: no developer agent ran for ${id}`)].join('; ')}`);
196
+ process.exit(1);
197
+ }
198
+ writeJson(path.join(dir, 'devcheck.json'), { verdicts, status: 'DONE', skipped });
199
+ out({ verdicts: verdicts.length });
200
+ } else throw new Error('merge must be candidates | devcheck');
201
+ }
202
+ else if (cmd === 'approval') {
203
+ const dir = runDir(need(flags, 'run'));
204
+ const set = emittable(readJson(`${dir}/tickets.json`).tickets, readJson(`${dir}/ready.json`), blockedTickets(loadRun(dir)));
205
+ const file = `${dir}/approval.md`;
206
+ if (!set.length) out({ tickets: [], status: 'NOTHING_TO_APPROVE', note: 'no ticket passed Gates C, R and D' });
207
+ else {
208
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
209
+ const state = checkApproval(cur, set);
210
+ if (!state.ok) fs.writeFileSync(file, approvalRequest(flags.run, set));
211
+ out({ file, tickets: set.map((t) => t.id), status: state.ok ? `APPROVED by ${state.by}` : 'PENDING', ...(state.by && !state.ok && { voided: state.reason }) });
212
+ }
213
+ }
214
+ else if (cmd === 'emit') {
215
+ const dir = runDir(need(flags, 'run'));
216
+ const tickets = readJson(`${dir}/tickets.json`).tickets, results = readJson(`${dir}/ready.json`), blocked = blockedTickets(loadRun(dir));
217
+ const set = emittable(tickets, results, blocked);
218
+ if (set.length) {
219
+ const file = `${dir}/approval.md`;
220
+ const ap = checkApproval(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '', set);
221
+ if (!ap.ok) { console.error(`error: emit needs human approval: ${ap.reason}`); process.exit(1); }
222
+ }
223
+ const r = emit(tickets, results, { gh: !!flags.gh, blocked });
224
+ fs.writeFileSync(`${dir}/tickets.md`, r.markdown);
225
+ out({ ready: r.ready, skipped: r.skipped, file: `${dir}/tickets.md` });
226
+ }
227
+ else if (cmd === 'report') {
228
+ const dir = runDir(need(flags, 'run'));
229
+ fs.writeFileSync(`${dir}/REPORT.md`, report(loadRun(dir), flags.run));
230
+ out({ file: `${dir}/REPORT.md` });
231
+ }
232
+ else if (cmd === 'eval') {
233
+ const dir = runDir(need(flags, 'run'));
234
+ const res = evaluate(readJson(need(flags, 'key')), loadRun(dir));
235
+ writeJson(`${dir}/eval.json`, res);
236
+ out(res);
237
+ }
238
+ else if (cmd === 'status') {
239
+ const dir = runDir(need(flags, 'run'));
240
+ const wf = readJson(path.join(workflowDir, 'workflow.json'));
241
+ out(wf.steps.map((s) => ({ step: s.id, kind: s.kind, done: fs.existsSync(path.join(dir, s.out)) })));
242
+ }
243
+ else { console.error(HELP); process.exit(2); }
244
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "traceticket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "From messy client call to verified ticket, with receipts. Parallel AI agents propose, scripts check every quote, humans approve. Installs into Claude Code, Cursor, Copilot, Windsurf, Codex, Gemini and any AGENTS.md-aware agent.",
5
+ "type": "module",
6
+ "bin": {
7
+ "traceticket": "bin/cli.js",
8
+ "sdlc-kit": "bin/cli.js"
9
+ },
10
+ "files": ["bin", "src", "workflow"],
11
+ "engines": { "node": ">=18" },
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "prepublishOnly": "node --test"
15
+ },
16
+ "keywords": [
17
+ "traceticket", "requirements", "user-stories", "gherkin", "acceptance-criteria", "transcript",
18
+ "ai-agents", "claude-code", "cursor", "agents-md", "quality-gates", "sdlc", "jira", "backlog"
19
+ ],
20
+ "license": "MIT"
21
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+ export const workflowDir = path.join(here, '..', 'workflow');
7
+ const load = () => JSON.parse(fs.readFileSync(path.join(workflowDir, 'workflow.json'), 'utf8'));
8
+ const read = (...p) => fs.readFileSync(path.join(workflowDir, ...p), 'utf8');
9
+ const ls = (...p) => fs.readdirSync(path.join(workflowDir, ...p));
10
+ const ORCHESTRATOR = 'transcript-to-tickets';
11
+
12
+ export function renderSteps(wf = load()) {
13
+ let phase = '';
14
+ return wf.steps.map((s, i) => {
15
+ const head = s.phase !== phase ? `\n### ${(phase = s.phase)}\n\n` : '';
16
+ const tag = [s.kind, s.gate && `Gate ${s.gate}`, s.isolated && 'isolated', s.optional && 'optional'].filter(Boolean).join(', ');
17
+ const how = s.run ? `Run \`${s.run}\`.`
18
+ : `Follow \`.sdlc/skills/${s.skill}/SKILL.md\`${s.agent ? ` (delegate to the \`${s.agent}\` agent)` : ''}. Writes \`${s.out}\`.`;
19
+ const loop = s.loop ? `\n Loop: ${s.loop}` : '';
20
+ if (s.kind === 'parallel') {
21
+ const spawn = s.spawn.map((x) => `\`${x.agent}\`${x.each ? ` once per ${x.each}${x.values ? ` (${x.values.join(', ')})` : ''}` : ' once'} → writes \`${x.out}\``).join('; ');
22
+ const hostRule = 'Send all spawns in ONE turn (one message with several agent calls). Never start them one after another, and never replace the fan-out with a single agent.';
23
+ return `${head}${i + 1}. **${s.id}** [parallel${s.always ? ', always' : ''}${s.gate ? `, Gate ${s.gate}` : ''}] ${s.desc}\n Spawn in parallel: ${spawn}. ${hostRule} Follow \`.sdlc/skills/${s.skill}/SKILL.md\`.${loop}`;
24
+ }
25
+ return `${head}${i + 1}. **${s.id}** [${tag}] ${s.desc}\n ${how}${loop}`;
26
+ }).join('\n');
27
+ }
28
+
29
+ const withSteps = (md) => md.replace('<!-- steps -->', `## Steps\n\nArtifacts live in \`${load().dir}\`. Run steps in order; do not skip or reorder.\n${renderSteps()}`);
30
+ const stripFm = (md) => md.replace(/^---\n[\s\S]*?\n---\n+/, '');
31
+ const skill = (name) => (name === ORCHESTRATOR ? withSteps : (x) => x)(read('skills', name, 'SKILL.md'));
32
+
33
+ // One canonical instruction body (the orchestrator skill), rendered into every host's native format.
34
+ export const renderBody = () => stripFm(skill(ORCHESTRATOR));
35
+
36
+ const fm = (obj) => `---\n${Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n\n`;
37
+ const DESC = 'Turn a client transcript, email or one-liner into grounded, product-checked, client-confirmed tickets.';
38
+ const MARK = (id) => [`<!-- sdlc-kit:${id}:start -->`, `<!-- sdlc-kit:${id}:end -->`];
39
+ const skillFiles = (root) => ls('skills').map((n) => [`${root}/${n}/SKILL.md`, skill(n)]);
40
+
41
+ // Thin slash commands that load a skill (Addy Osmani / ECC pattern: commands are shims, skills hold the process).
42
+ // Verb-first names, one canonical command per job.
43
+ const COMMANDS = {
44
+ 'call-to-tickets': [ORCHESTRATOR, '<transcript file> [--product <dir>] [--client <answers.md>]'],
45
+ 'grill-client': ['client-grilling', '<run>'],
46
+ 'review-tickets': ['ticket-readiness-review', '<run>'],
47
+ 'run-report': ['run-reporting', '<run>'],
48
+ };
49
+ const command = ([name, hint]) => fm({ description: read('skills', name, 'SKILL.md').match(/description: (.+)/)[1].split('. ')[0] + '.', 'argument-hint': hint })
50
+ + `Use the \`${name}\` skill (\`.sdlc/skills/${name}/SKILL.md\`) and follow it exactly.\n\nInput: $ARGUMENTS\n`;
51
+ // Cursor custom commands are plain Markdown; whatever is typed after the command is the input.
52
+ const cursorCommand = (cmd, [skill, hint]) => `# /${cmd} ${hint}\n\nUse the \`${skill}\` skill (\`.sdlc/skills/${skill}/SKILL.md\`) and follow it exactly.\n\nInput: the text the user typed after the command (${hint}).\n`;
53
+
54
+ // Cursor agents take name, model and description; tools are not declared and the model follows the user's choice.
55
+ const cursorAgent = (md) => md.replace(/^tools: .*\n/m, '').replace(/^model: .*$/m, 'model: inherit');
56
+
57
+ // kind 'files' writes files we own; kind 'section' upserts a marked block in a shared file.
58
+ export const ADAPTERS = {
59
+ claude: { kind: 'files', files: () => [...skillFiles('.claude/skills'), ...ls('agents').map((a) => [`.claude/agents/${a}`, read('agents', a)])] },
60
+ 'claude-command': { kind: 'files', files: () => Object.entries(COMMANDS).map(([c, v]) => [`.claude/commands/${c}.md`, command(v)]) },
61
+ cursor: { kind: 'files', files: (b) => [['.cursor/rules/sdlc.mdc', fm({ description: DESC, alwaysApply: 'false' }) + b], ...skillFiles('.cursor/skills'),
62
+ ...ls('agents').map((a) => [`.cursor/agents/${a}`, cursorAgent(read('agents', a))]),
63
+ ...Object.entries(COMMANDS).map(([c, v]) => [`.cursor/commands/${c}.md`, cursorCommand(c, v)])] },
64
+ windsurf: { kind: 'files', files: (b) => [['.windsurf/rules/sdlc.md', fm({ trigger: 'model_decision', description: DESC }) + b]] },
65
+ cline: { kind: 'files', files: (b) => [['.clinerules/sdlc.md', b]] },
66
+ copilot: { kind: 'section', path: '.github/copilot-instructions.md', id: 'copilot' },
67
+ agents: { kind: 'section', path: 'AGENTS.md', id: 'agents' }, // Codex, Aider, Zed, Jules, opencode, etc.
68
+ gemini: { kind: 'section', path: 'GEMINI.md', id: 'gemini' },
69
+ opencode: { kind: 'files', files: (b) => [['.opencode/command/sdlc.md', fm({ description: DESC }) + b + '\nInput: $ARGUMENTS\n']] },
70
+ };
71
+
72
+ export const ALL = Object.keys(ADAPTERS);
73
+
74
+ export function install(targets, cwd = process.cwd(), { dryRun = false } = {}) {
75
+ const body = renderBody();
76
+ const written = [];
77
+ const put = (rel, content) => { if (dryRun) return; const f = path.join(cwd, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, content); };
78
+
79
+ // Shared, host-independent assets: every host's instructions point here.
80
+ for (const [p, c] of skillFiles('.sdlc/skills')) put(p, c);
81
+ for (const a of ls('agents')) put(`.sdlc/agents/${a}`, read('agents', a));
82
+ for (const r of ls('references')) put(`.sdlc/references/${r}`, read('references', r));
83
+ put('.sdlc/workflow.json', read('workflow.json'));
84
+ put('.sdlc/INSTRUCTIONS.md', body);
85
+ written.push('.sdlc/');
86
+
87
+ // Runs hold client transcripts and answers: keep them out of git in every repo we install into.
88
+ const gi = path.join(cwd, '.gitignore');
89
+ const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
90
+ if (!/^\/?\.sdlc\/runs\/?$/m.test(cur)) {
91
+ put('.gitignore', `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}\n# TraceTicket: runs hold client transcripts\n.sdlc/runs/\n`);
92
+ written.push('.gitignore (+ .sdlc/runs/)');
93
+ }
94
+
95
+ for (const t of targets) {
96
+ const a = ADAPTERS[t];
97
+ if (!a) throw new Error(`unknown target "${t}". Valid: ${ALL.join(', ')}`);
98
+ if (a.kind === 'files') for (const [p, c] of a.files(body)) { put(p, c); written.push(p); }
99
+ else {
100
+ const [s, e] = MARK(a.id);
101
+ const block = `${s}\n${body}${e}\n`;
102
+ const f = path.join(cwd, a.path);
103
+ const cur = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
104
+ const re = new RegExp(`${s}[\\s\\S]*?${e}\\n?`);
105
+ put(a.path, re.test(cur) ? cur.replace(re, block) : (cur ? cur.replace(/\n*$/, '\n\n') : '') + block);
106
+ written.push(a.path);
107
+ }
108
+ }
109
+
110
+ return written;
111
+ }
112
+
113
+ // Pick targets from what the repo already uses.
114
+ export function detect(cwd = process.cwd()) {
115
+ const has = (p) => fs.existsSync(path.join(cwd, p));
116
+ const t = [];
117
+ if (has('.claude') || has('CLAUDE.md')) t.push('claude', 'claude-command');
118
+ if (has('.cursor')) t.push('cursor');
119
+ if (has('.opencode') || has('opencode.json')) t.push('opencode');
120
+ if (has('.gemini') || has('GEMINI.md')) t.push('gemini');
121
+ if (has('.windsurf') || has('.windsurfrules')) t.push('windsurf');
122
+ if (has('.clinerules')) t.push('cline');
123
+ if (has('.github')) t.push('copilot');
124
+ t.push('agents'); // always: the universal fallback
125
+ return t;
126
+ }
@@ -0,0 +1,53 @@
1
+ import crypto from 'node:crypto';
2
+ import { norm } from './util.js';
3
+ import { toMarkdown } from './emit.js';
4
+
5
+ // A human signs off on the exact ticket set that will be emitted. The fingerprint covers what a reader
6
+ // approves (id, summary, story, scenarios and their quotes), so editing a ticket after approval voids it.
7
+ export const ticketSetHash = (tickets) => crypto.createHash('sha256').update(JSON.stringify(
8
+ tickets.map((t) => [t.id, t.summary ?? t.title, t.story, t.acceptance_criteria?.map((a) => [a.scenario, a.given, a.when, a.then, a.gherkin, a.source])]),
9
+ )).digest('hex').slice(0, 16);
10
+
11
+ // The whole review packet in one file: a human reads the tickets here and signs at the bottom.
12
+ export function approvalRequest(run, tickets) {
13
+ const list = tickets.map((t) => `- [ ] ${t.id}: ${t.summary ?? t.title} (${t.acceptance_criteria?.length ?? 0} scenarios, ${t.product_fit?.label ?? 'no fit'})`).join('\n');
14
+ return `# Approval to emit: ${run}
15
+
16
+ Ticket set: sha256:${ticketSetHash(tickets)}
17
+
18
+ Approving files these ${tickets.length} ticket(s). Read each one below, check that every quote is what the client really said, then sign at the bottom.
19
+
20
+ ## Checklist
21
+
22
+ ${list}
23
+
24
+ ## Decision (human only)
25
+
26
+ Replace the status line at the bottom with one of:
27
+
28
+ - \`APPROVED by <your name>\` to file these tickets
29
+ - \`REJECTED by <your name>: <reason>\` to send them back
30
+
31
+ Agents must never edit this file. \`traceticket emit\` refuses to run without a signature, and any change to the tickets afterwards voids it.
32
+
33
+ Status: PENDING
34
+
35
+ ---
36
+
37
+ ## Tickets
38
+
39
+ ${tickets.map(toMarkdown).join('\n---\n\n')}`;
40
+ }
41
+
42
+ // Returns { ok, by, reason }. The placeholder "<name>" and an empty name never count.
43
+ export function checkApproval(text, tickets) {
44
+ if (!text) return { ok: false, reason: 'approval.md does not exist: run `traceticket approval --run <run>`, then have a human approve it' };
45
+ const rej = /^REJECTED by (.+?)\s*$/m.exec(text);
46
+ if (rej) return { ok: false, rejected: true, reason: `rejected by ${norm(rej[1])}: fix the tickets, then run \`traceticket approval --run <run>\` again` };
47
+ const m = /^APPROVED by (.+?)\s*$/m.exec(text);
48
+ const by = m && norm(m[1]);
49
+ if (!by || /^<.*>$/.test(by) || by.toLowerCase() === 'name') return { ok: false, reason: 'approval.md has no "APPROVED by <name>" line from a human' };
50
+ const h = /^Ticket set: sha256:([0-9a-f]+)\s*$/m.exec(text)?.[1];
51
+ if (h !== ticketSetHash(tickets)) return { ok: false, by, reason: 'the tickets changed after approval (ticket set fingerprint differs): run `traceticket approval --run <run>` and approve again' };
52
+ return { ok: true, by };
53
+ }
package/src/context.js ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Folders that never hold product truth (dependencies, build output, our own run data).
5
+ export const SKIP = new Set(['node_modules', '.git', '.sdlc', 'dist', 'build', 'out', '.next', '.nuxt', 'coverage',
6
+ 'vendor', '.venv', 'venv', '__pycache__', 'target', '.idea', '.vscode', '.claude', '.cursor', '.turbo', '.cache',
7
+ 'test-results', 'playwright-report', 'storybook-static']);
8
+ const DOC_EXT = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
9
+ // A folder or file must be named for the kind (docs/prd/, PRD-swaps.md, requirements.md), not merely contain the word.
10
+ const named = (words, prefixes) => new RegExp(`(^|/)(${words})(/|\\.[a-z]+$)|(^|/)(${prefixes})[-_ ]`, 'i');
11
+ const KIND = [
12
+ ['prd', named('prds?|frds?|srs|requirements?|product[-_ ]?requirements?|brd|mrd', 'prd|frd|srs|brd|mrd')],
13
+ ['adr', named('adrs?|decisions?|decision[-_ ]?records?', 'adr')],
14
+ ['spec', named('specs?|rfcs?|design|architecture|features?|roadmap|user[-_ ]?stories', 'spec|rfc')],
15
+ ['glossary', /(^|\/)((project|product|domain)[_-])?(context|overview|glossary|domain|ubiquitous[-_ ]?language)\.(md|mdx)$/i],
16
+ ['readme', /(^|\/)readme\.(md|mdx|rst|txt)$/i],
17
+ ];
18
+ // Housekeeping files, dependency lists and agent instruction files describe the repo's tooling, not the product.
19
+ const NOISE = /(^|\/)((changelog|license|licence|code_of_conduct|contributing|security|skill|agents|claude|gemini|copilot-instructions)\.(md|txt)|requirements[\w.-]*\.txt)$/i;
20
+
21
+ // A README inside a docs tree describes a feature (docs/authentication/README.md), so it ranks with specs.
22
+ const kindOf = (rel) => {
23
+ const k = KIND.find(([, re]) => re.test(rel))?.[0] ?? 'doc';
24
+ return k === 'readme' && /(^|\/)docs?\/.+\//i.test(rel) ? 'spec' : k;
25
+ };
26
+ // Dot-folders (.git, .agents, .cursor, .github ...) hold tooling and config. Only their top-level
27
+ // overview/context/glossary files count (e.g. .agents/PROJECT_OVERVIEW.md); the rest is skipped.
28
+ const skipDir = (name) => name.startsWith('.') || SKIP.has(name);
29
+ const CONTEXT_FILE = /^((project|product|domain)[_-])?(overview|context|glossary)\.(md|mdx)$/i;
30
+
31
+ // Finds the documents that describe what the product does: PRDs, specs, ADRs, glossaries, READMEs.
32
+ // Paths are relative to root and use forward slashes, so agents and gates cite them the same way.
33
+ export function discover(root, { limit = 1000 } = {}) {
34
+ const files = [];
35
+ const add = (dir, name) => {
36
+ const rel = path.relative(root, path.join(dir, name)).split(path.sep).join('/');
37
+ if (DOC_EXT.test(name) && !NOISE.test(rel)) files.push({ path: rel, kind: kindOf(rel), bytes: fs.statSync(path.join(dir, name)).size });
38
+ };
39
+ // Inside a dot-folder, only its top-level overview/context/glossary files (e.g. .agents/PROJECT_OVERVIEW.md).
40
+ const peek = (dir) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) if (e.isFile() && CONTEXT_FILE.test(e.name)) add(dir, e.name); };
41
+ const walk = (dir) => {
42
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ if (e.isDirectory()) {
44
+ if (!skipDir(e.name)) walk(path.join(dir, e.name));
45
+ else if (e.name.startsWith('.') && !SKIP.has(e.name)) peek(path.join(dir, e.name));
46
+ continue;
47
+ }
48
+ add(dir, e.name);
49
+ }
50
+ };
51
+ walk(root);
52
+ const rank = ['prd', 'spec', 'adr', 'glossary', 'readme', 'doc'];
53
+ files.sort((a, b) => rank.indexOf(a.kind) - rank.indexOf(b.kind) || a.path.localeCompare(b.path));
54
+ const counts = Object.fromEntries(rank.map((k) => [k, files.filter((f) => f.kind === k).length]));
55
+ return { root: '.', counts, truncated: files.length > limit, files: files.slice(0, limit) };
56
+ }
57
+
58
+ // Never accepted as evidence, so an agent has no reason to open them.
59
+ const SECRET = /^\.env(\..*)?$|\.(pem|key|p12|pfx)$|^(id_rsa|id_ed25519)|secret|credential/i;
60
+
61
+ // Reads a repo file for evidence checks. Refuses paths outside root, inside SKIP folders, or that look like secrets.
62
+ export function repoReader(root) {
63
+ const base = path.resolve(root);
64
+ return (rel) => {
65
+ if (!rel || typeof rel !== 'string') return null;
66
+ const abs = path.resolve(base, rel);
67
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null;
68
+ const parts = path.relative(base, abs).split(path.sep);
69
+ const peekable = parts.length === 2 && parts[0].startsWith('.') && !SKIP.has(parts[0]) && CONTEXT_FILE.test(parts[1]);
70
+ if (!peekable && parts.slice(0, -1).some(skipDir)) return null;
71
+ if (SECRET.test(path.basename(abs))) return null;
72
+ try { return fs.statSync(abs).isFile() ? fs.readFileSync(abs, 'utf8') : null; } catch { return null; }
73
+ };
74
+ }