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.
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/bin/cli.js +244 -0
- package/package.json +19 -4
- package/src/adapters.js +126 -0
- package/src/approval.js +53 -0
- package/src/context.js +74 -0
- package/src/emit.js +43 -0
- package/src/eval.js +53 -0
- package/src/gates.js +204 -0
- package/src/grill.js +101 -0
- package/src/ingest.js +19 -0
- package/src/merge.js +100 -0
- package/src/report.js +160 -0
- package/src/util.js +35 -0
- package/workflow/agents/sdlc-client-sim.md +34 -0
- package/workflow/agents/sdlc-developer.md +35 -0
- package/workflow/agents/sdlc-extractor.md +37 -0
- package/workflow/agents/sdlc-product-analyst.md +40 -0
- package/workflow/agents/sdlc-reviewer.md +36 -0
- package/workflow/agents/sdlc-ticket-writer.md +38 -0
- package/workflow/agents/sdlc-verifier.md +36 -0
- package/workflow/references/definition-of-ready.md +18 -0
- package/workflow/references/json-contracts.md +89 -0
- package/workflow/references/prompt-defense.md +10 -0
- package/workflow/references/question-format.md +26 -0
- package/workflow/references/ticket-template.md +83 -0
- package/workflow/references/vague-lexicon.md +16 -0
- package/workflow/skills/clarification-planning/SKILL.md +64 -0
- package/workflow/skills/client-grilling/SKILL.md +89 -0
- package/workflow/skills/client-simulation/SKILL.md +44 -0
- package/workflow/skills/developer-readiness-check/SKILL.md +56 -0
- package/workflow/skills/grounding-verification/SKILL.md +70 -0
- package/workflow/skills/product-fit-analysis/SKILL.md +86 -0
- package/workflow/skills/requirement-extraction/SKILL.md +87 -0
- package/workflow/skills/run-reporting/SKILL.md +50 -0
- package/workflow/skills/ticket-readiness-review/SKILL.md +70 -0
- package/workflow/skills/ticket-writing/SKILL.md +92 -0
- package/workflow/skills/transcript-to-tickets/SKILL.md +114 -0
- 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
|
-
#
|
|
1
|
+
# TraceTicket
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/adapters.js
ADDED
|
@@ -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
|
+
}
|
package/src/approval.js
ADDED
|
@@ -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
|
+
}
|