tewip-ai 0.22.0 → 0.24.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/README.md +5 -1
- package/dist/cli/help.js +12 -1
- package/dist/cli/tewip.js +17 -0
- package/dist/engine/attest/attest-command.js +24 -1
- package/dist/engine/attest/attest.js +24 -2
- package/dist/engine/attest/seal.js +90 -0
- package/dist/engine/history.js +4 -1
- package/dist/engine/pursue/policy.js +91 -0
- package/dist/engine/pursue/pursue-command.js +125 -0
- package/dist/engine/recall/recall-command.js +13 -6
- package/dist/engine/timing-rules.js +9 -1
- package/dist/engine/trace/trace-command.js +8 -5
- package/dist/mcp/server.js +47 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pyte
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| **Terminal** | `npm install -g tewip-ai`, then `tewip explain report.xml`, `tewip ci owner/repo --pr 12`, `tewip mcp` |
|
|
10
10
|
| **VS Code, Cursor, Windsurf, VSCodium** | the Tewip extension ([download](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)): registers Tewip with the editor's AI assistant and adds six commands - explain a report, why CI failed on this branch, the risk review of your change, which flaky tests to quarantine, the security check, and what your corrections add up to |
|
|
11
|
-
| **JetBrains, Claude, other AI assistants** | add the MCP server `tewip mcp
|
|
11
|
+
| **JetBrains, Claude, other AI assistants** | add the MCP server `tewip mcp`: fifteen tools, including *has this happened here before?*, *is this requirement covered and passing?* and *what should be done next about this goal?* (see [editor setup](#use-it-from-your-editor-any-ide-that-speaks-mcp)) |
|
|
12
12
|
| **Any model** | OpenAI, Claude, Gemini, Azure, or a model on your own machine with Ollama (no account, nothing leaves the computer): `TEWIP_PROVIDER` |
|
|
13
13
|
| **GitHub CI** | the Tewip Action: a comment on every pull request, an optional merge gate, verified fix PRs ([QUICKSTART](QUICKSTART.md)) |
|
|
14
14
|
|
|
@@ -71,6 +71,10 @@ On every run, the action:
|
|
|
71
71
|
- **Developers:** posts one diagnosis comment on the pull request and keeps it updated. For each failure it says whether the change likely caused it, why, and what to do next.
|
|
72
72
|
- **SDETs:** writes the triage board into the job summary.
|
|
73
73
|
- **DevOps:** collects environment failures (server errors, connection resets, outages) in **one** GitHub issue, and each new run adds a single grouped comment to it.
|
|
74
|
+
- **Evidence, every run:** the Action writes an attestation into its artifact - the claims with
|
|
75
|
+
their evidence and which layer decided each, what Tewip **refused** to decide, and what it did
|
|
76
|
+
not check at all. Nothing enters it that Tewip did not verify itself, so a release decision can
|
|
77
|
+
be taken from it without opening CI.
|
|
74
78
|
- **Fixes (`autofix: true`):** for drifted locators that the deterministic rule matched to a renamed element, it rewrites the locator, re-runs that test, and opens a separate pull request with the changes that passed. It never merges and never edits application code.
|
|
75
79
|
- **Every role:** uploads all role views as the `tewip-report` artifact.
|
|
76
80
|
- **When the job dies before any test runs** (an image that would not pull, a runner out of disk, a service container that never came up): it reads the failed job's log and names the cause, instead of leaving a red X with no explanation.
|
package/dist/cli/help.js
CHANGED
|
@@ -100,6 +100,13 @@ Usage:
|
|
|
100
100
|
same way more than once, which rule decided it, and the failures
|
|
101
101
|
it came from. Says "not yet" rather than inventing a pattern
|
|
102
102
|
from two cases. No model, no cost.
|
|
103
|
+
tewip pursue "<a goal>" [--act] [--out next.md]
|
|
104
|
+
Give Tewip a goal instead of a verb - "keep the checkout flow
|
|
105
|
+
green" - and it says what it would do next and why, from what
|
|
106
|
+
it can actually see. The order is fixed and written down: a
|
|
107
|
+
real bug first, then the environment, then flakiness, then a
|
|
108
|
+
coverage gap. It decides; it does not act unless you ask, and
|
|
109
|
+
even then only through the gates that prove a change.
|
|
103
110
|
tewip trace [--only "<ticket or title>"] [--out trace.md]
|
|
104
111
|
Is this requirement covered, and is it passing right now? Joins
|
|
105
112
|
the plans Tewip wrote to the tests that cover them and the runs
|
|
@@ -118,10 +125,14 @@ Usage:
|
|
|
118
125
|
it fails for the incident's own reason. A test that passes, or
|
|
119
126
|
fails its own way, is reported and thrown away.
|
|
120
127
|
tewip attest [--run <id>] [--out attestation.md] [--json bundle.json]
|
|
128
|
+
tewip attest --verify <bundle.json> [--key <public.pem>]
|
|
121
129
|
What Tewip checked for a build, as one artifact: the claims with
|
|
122
130
|
their evidence and which layer decided each, what it refused to
|
|
123
131
|
decide and why, and what it did not check at all. Nothing enters
|
|
124
|
-
it that Tewip did not verify itself.
|
|
132
|
+
it that Tewip did not verify itself. Every bundle carries a
|
|
133
|
+
digest; set TEWIP_ATTEST_KEY to an ed25519 private key to sign
|
|
134
|
+
it too. --verify says what it checked and what that does not
|
|
135
|
+
establish - a digest means undamaged, not unedited. No cost.
|
|
125
136
|
tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
|
|
126
137
|
tewip --version Print the version.
|
|
127
138
|
|
package/dist/cli/tewip.js
CHANGED
|
@@ -177,6 +177,14 @@ async function main() {
|
|
|
177
177
|
runLearn({ root: process.cwd(), name: flag(args, 'name'), labels: flag(args, 'labels'), out: flag(args, 'out') });
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
|
+
case 'pursue': {
|
|
181
|
+
const goal = args.filter((a, i) => !a.startsWith('--') && !['--name', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
182
|
+
if (!goal)
|
|
183
|
+
throw new Error('usage: tewip pursue "<a goal, e.g. keep the checkout flow green>" [--act]');
|
|
184
|
+
const { runPursue } = await import('../engine/pursue/pursue-command.js');
|
|
185
|
+
runPursue({ root: process.cwd(), goal, act: args.includes('--act'), name: flag(args, 'name'), out: flag(args, 'out') });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
180
188
|
case 'trace': {
|
|
181
189
|
const { runTrace } = await import('../engine/trace/trace-command.js');
|
|
182
190
|
runTrace({ root: process.cwd(), only: flag(args, 'only'), name: flag(args, 'name'), out: flag(args, 'out') });
|
|
@@ -202,6 +210,15 @@ async function main() {
|
|
|
202
210
|
return;
|
|
203
211
|
}
|
|
204
212
|
case 'attest': {
|
|
213
|
+
const verify = flag(args, 'verify');
|
|
214
|
+
if (verify) {
|
|
215
|
+
const { verifyAttestation } = await import('../engine/attest/attest-command.js');
|
|
216
|
+
const { ok, report } = verifyAttestation(verify, flag(args, 'key'));
|
|
217
|
+
console.log(report);
|
|
218
|
+
if (!ok)
|
|
219
|
+
process.exitCode = 1;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
205
222
|
const { runAttest } = await import('../engine/attest/attest-command.js');
|
|
206
223
|
runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
|
|
207
224
|
return;
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
// `tewip attest`: the evidence for one run, as an artifact a build can carry.
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
|
|
5
5
|
import { ENGINE_VERSION } from '../engine.js';
|
|
6
6
|
import { attestRun, renderAttestation } from './attest.js';
|
|
7
|
+
import { check } from './seal.js';
|
|
8
|
+
/**
|
|
9
|
+
* Checks a bundle somebody was handed. It reports what the check establishes and, just as
|
|
10
|
+
* importantly, what it does not: a digest that matches proves the file is undamaged, and only a
|
|
11
|
+
* signature checked against a key says who made it.
|
|
12
|
+
*/
|
|
13
|
+
export function verifyAttestation(file, keyFile) {
|
|
14
|
+
const bundle = JSON.parse(readFileSync(resolve(file), 'utf8'));
|
|
15
|
+
const publicKey = keyFile ? readFileSync(resolve(keyFile), 'utf8') : undefined;
|
|
16
|
+
const checked = check(bundle, publicKey);
|
|
17
|
+
const ok = checked.contentMatches && checked.signature !== 'invalid';
|
|
18
|
+
const report = [
|
|
19
|
+
`# ${ok ? 'Checked' : 'Do not trust this bundle'}: ${file.split(/[\/]/).pop()}`,
|
|
20
|
+
'',
|
|
21
|
+
`Content matches its digest: ${checked.contentMatches ? 'yes' : 'NO'}`,
|
|
22
|
+
`Signature: ${checked.signature.replace(/-/g, ' ')}`,
|
|
23
|
+
'',
|
|
24
|
+
checked.says + '.',
|
|
25
|
+
'',
|
|
26
|
+
...(checked.signature === 'no-key-given' ? ['Pass `--key <public.pem>` to check the signature itself.'] : []),
|
|
27
|
+
].join(String.fromCharCode(10));
|
|
28
|
+
return { ok, report };
|
|
29
|
+
}
|
|
7
30
|
/** Everything Tewip kept for one project, newest run last. */
|
|
8
31
|
function history(root, only) {
|
|
9
32
|
const dir = join(root, '.tewip', 'history');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { seal } from './seal.js';
|
|
1
3
|
const sourceOf = (record) => String(record.diagnosis?.source ?? '');
|
|
2
4
|
/** The evidence line a person reads: the first thing the deciding layer actually said. */
|
|
3
5
|
function evidenceOf(record) {
|
|
@@ -33,7 +35,7 @@ export function attestRun(project, run, records, { checked, notChecked, tool, en
|
|
|
33
35
|
confidence: Number(diagnosis?.confidence ?? 0),
|
|
34
36
|
});
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
const bundle = {
|
|
37
39
|
project,
|
|
38
40
|
commit: run.gitSha ?? '',
|
|
39
41
|
runId: run.runId,
|
|
@@ -47,6 +49,22 @@ export function attestRun(project, run, records, { checked, notChecked, tool, en
|
|
|
47
49
|
refusals,
|
|
48
50
|
producedBy: { tool, engine, at: new Date().toISOString() },
|
|
49
51
|
};
|
|
52
|
+
// Sealed last, over everything above: the digest is of the content, never of itself.
|
|
53
|
+
return { ...bundle, seal: seal(bundle, signingKey()) };
|
|
54
|
+
}
|
|
55
|
+
/** The Ed25519 private key to sign with, from TEWIP_ATTEST_KEY: a PEM, or a path to one. */
|
|
56
|
+
function signingKey() {
|
|
57
|
+
const given = process.env.TEWIP_ATTEST_KEY?.trim();
|
|
58
|
+
if (!given)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (given.includes('BEGIN'))
|
|
61
|
+
return given;
|
|
62
|
+
try {
|
|
63
|
+
return readFileSync(given, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(`TEWIP_ATTEST_KEY points at ${given}, which could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
50
68
|
}
|
|
51
69
|
/** The bundle as a person reads it. The refusals are not hidden at the bottom; they are the point. */
|
|
52
70
|
export function renderAttestation(a) {
|
|
@@ -84,6 +102,10 @@ export function renderAttestation(a) {
|
|
|
84
102
|
lines.push(`- **${refusal.what}**`);
|
|
85
103
|
lines.push(` - Why: ${refusal.why}`);
|
|
86
104
|
}
|
|
87
|
-
lines.push('', '---', '', `Produced by ${a.producedBy.tool} (${a.producedBy.engine}) at ${a.producedBy.at.slice(0, 19).replace('T', ' ')} UTC.`,
|
|
105
|
+
lines.push('', '---', '', `Produced by ${a.producedBy.tool} (${a.producedBy.engine}) at ${a.producedBy.at.slice(0, 19).replace('T', ' ')} UTC.`, a.seal?.signature
|
|
106
|
+
? `Signed (${a.seal.algorithm}); digest ${a.seal.digest.slice(0, 16)}…. Check it with \`tewip attest --verify <bundle.json> --key <public.pem>\`.`
|
|
107
|
+
: a.seal
|
|
108
|
+
? `Digest ${a.seal.digest.slice(0, 16)}…, which is not a signature: it shows the bundle is undamaged, not that nobody edited it. Set TEWIP_ATTEST_KEY to sign.`
|
|
109
|
+
: '', 'Every claim above names the run it came from and the layer that decided it. This is a statement', 'about the checks listed under "what was checked" and nothing else.');
|
|
88
110
|
return lines.join('\n');
|
|
89
111
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Sealing an attestation, and being precise about what a seal is worth.
|
|
2
|
+
//
|
|
3
|
+
// The roadmap promised a "signed" bundle and the first implementation signed nothing. This closes
|
|
4
|
+
// that, but the honest part is the distinction it refuses to blur:
|
|
5
|
+
//
|
|
6
|
+
// A **digest** says the content matches the fingerprint recorded inside it. Anybody who edits the
|
|
7
|
+
// bundle can recompute the fingerprint, so on its own it proves only that nothing was corrupted
|
|
8
|
+
// in transit - not that nobody changed it on purpose. It is still worth having: it gives a bundle
|
|
9
|
+
// a stable identity that a release note or a ticket can quote.
|
|
10
|
+
//
|
|
11
|
+
// A **signature** says the holder of a private key produced this exact content. That is the claim
|
|
12
|
+
// people mean by "signed", and it is only available when a key exists.
|
|
13
|
+
//
|
|
14
|
+
// A tool that prints "signed" over a bare hash is doing the thing this whole project exists not to
|
|
15
|
+
// do, so the words are kept apart everywhere: in the type, in the report, and in the verifier.
|
|
16
|
+
import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
|
|
17
|
+
/**
|
|
18
|
+
* The bundle's content, in a form that hashes the same everywhere: keys in order, the seal itself
|
|
19
|
+
* left out, and the time it was produced kept in, because a bundle for a different moment is a
|
|
20
|
+
* different bundle.
|
|
21
|
+
*/
|
|
22
|
+
export function canonical(bundle) {
|
|
23
|
+
const ordered = (value) => {
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return value.map(ordered);
|
|
26
|
+
if (value && typeof value === 'object') {
|
|
27
|
+
return Object.fromEntries(Object.entries(value)
|
|
28
|
+
.filter(([k]) => k !== 'seal')
|
|
29
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
30
|
+
.map(([k, v]) => [k, ordered(v)]));
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
return JSON.stringify(ordered(bundle));
|
|
35
|
+
}
|
|
36
|
+
export const digestOf = (bundle) => createHash('sha256').update(canonical(bundle)).digest('hex');
|
|
37
|
+
/**
|
|
38
|
+
* Seals a bundle: always a digest, and a signature when a key is given. The key is a PEM Ed25519
|
|
39
|
+
* private key, from `TEWIP_ATTEST_KEY` (a path or the PEM itself). Nothing is generated silently:
|
|
40
|
+
* without a key there is no signature and the bundle says so rather than implying one.
|
|
41
|
+
*/
|
|
42
|
+
export function seal(bundle, privateKeyPem) {
|
|
43
|
+
const digest = digestOf(bundle);
|
|
44
|
+
if (!privateKeyPem?.trim())
|
|
45
|
+
return { digest };
|
|
46
|
+
try {
|
|
47
|
+
const key = createPrivateKey(privateKeyPem);
|
|
48
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
49
|
+
throw new Error(`the signing key is ${key.asymmetricKeyType}, and Tewip signs with ed25519`);
|
|
50
|
+
return { digest, signature: cryptoSign(null, Buffer.from(digest, 'hex'), key).toString('base64'), algorithm: 'ed25519' };
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// A key that does not work is not a reason to ship an unmarked bundle quietly.
|
|
54
|
+
throw new Error(`the attestation could not be signed: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Checks a sealed bundle, and says exactly what that check does and does not establish. */
|
|
58
|
+
export function check(bundle, publicKeyPem) {
|
|
59
|
+
const recorded = bundle.seal?.digest ?? '';
|
|
60
|
+
const contentMatches = Boolean(recorded) && recorded === digestOf(bundle);
|
|
61
|
+
if (!recorded) {
|
|
62
|
+
return { contentMatches: false, signature: 'absent', says: 'this bundle carries no digest at all, so nothing about it can be checked' };
|
|
63
|
+
}
|
|
64
|
+
if (!contentMatches) {
|
|
65
|
+
return { contentMatches: false, signature: bundle.seal?.signature ? 'invalid' : 'absent', says: 'the content does not match the digest recorded inside it: it has been changed since it was written' };
|
|
66
|
+
}
|
|
67
|
+
if (!bundle.seal?.signature) {
|
|
68
|
+
return {
|
|
69
|
+
contentMatches: true,
|
|
70
|
+
signature: 'absent',
|
|
71
|
+
says: 'the content matches its own digest, and nothing more: no signature was made, so this shows the bundle is undamaged, not that nobody edited it - an editor can recompute a digest',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!publicKeyPem?.trim()) {
|
|
75
|
+
return { contentMatches: true, signature: 'no-key-given', says: 'the content matches its own digest and carries a signature, but no public key was given, so the signature was not checked' };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const ok = cryptoVerify(null, Buffer.from(recorded, 'hex'), createPublicKey(publicKeyPem), Buffer.from(bundle.seal.signature, 'base64'));
|
|
79
|
+
return {
|
|
80
|
+
contentMatches: true,
|
|
81
|
+
signature: ok ? 'valid' : 'invalid',
|
|
82
|
+
says: ok
|
|
83
|
+
? 'the content matches its digest and the signature is the holder of that key over this exact content'
|
|
84
|
+
: 'the content matches its digest, but the signature does not belong to this key and this content',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return { contentMatches: true, signature: 'invalid', says: `the signature could not be checked: ${error instanceof Error ? error.message : String(error)}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/engine/history.js
CHANGED
|
@@ -94,7 +94,10 @@ export class HistoryIndex {
|
|
|
94
94
|
}
|
|
95
95
|
runContext(runId) {
|
|
96
96
|
const e = this.runs[this.indexOf(runId)];
|
|
97
|
-
|
|
97
|
+
// "Some run knows its commit" is the weakest true statement available: a single kept run with a
|
|
98
|
+
// sha means the history can distinguish code changes from flakiness at all.
|
|
99
|
+
const commitsKnown = this.runs.some((r) => Boolean(r.sha));
|
|
100
|
+
return { runId, branch: e.branch, testsAttempted: Object.keys(e.attempts).length, failures: e.failures, commitsKnown };
|
|
98
101
|
}
|
|
99
102
|
indexOf(runId) {
|
|
100
103
|
const i = this.position.get(runId);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const list = (items, max = 3) => items.slice(0, max).join(', ') + (items.length > max ? `, and ${items.length - max} more` : '');
|
|
2
|
+
/**
|
|
3
|
+
* What to do next about a goal, in one pass, most urgent first.
|
|
4
|
+
*
|
|
5
|
+
* The order is the argument. A real bug beats everything, because the goal is already broken. The
|
|
6
|
+
* environment comes next, because nothing about the code has been shown and touching tests would
|
|
7
|
+
* be guessing. Flakiness comes third: the code may be fine and the test is lying. Only when
|
|
8
|
+
* nothing is failing is a coverage gap worth spending on, and only when nothing is failing and
|
|
9
|
+
* nothing is missing is the goal worth attesting to.
|
|
10
|
+
*/
|
|
11
|
+
export function decide(goal, seen) {
|
|
12
|
+
if (seen.noHistory) {
|
|
13
|
+
return {
|
|
14
|
+
act: 'look-first',
|
|
15
|
+
next: 'run `tewip explain <report>` on a test report, so there is something to reason about',
|
|
16
|
+
why: 'no run has been kept for this project, so nothing is known about the goal yet - and guessing from the name of a goal would be inventing',
|
|
17
|
+
evidence: [],
|
|
18
|
+
changesFiles: false,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const realBugs = seen.failing.filter((f) => f.category === 'REAL_BUG');
|
|
22
|
+
if (realBugs.length > 0) {
|
|
23
|
+
return {
|
|
24
|
+
act: 'triage-real-bug',
|
|
25
|
+
next: `put ${realBugs.length} failing test(s) in front of a person: ${list(realBugs.map((f) => f.testId))}`,
|
|
26
|
+
why: `the goal is already broken: Tewip calls ${realBugs.length === 1 ? 'this failure' : 'these failures'} a real bug, which is the one kind it will not work around`,
|
|
27
|
+
evidence: realBugs.map((f) => `${f.testId}: REAL_BUG at ${f.confidence.toFixed(2)}`),
|
|
28
|
+
changesFiles: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const env = seen.failing.filter((f) => f.category === 'ENV');
|
|
32
|
+
if (env.length > 0 && env.length === seen.failing.length) {
|
|
33
|
+
return {
|
|
34
|
+
act: 'rerun-environment',
|
|
35
|
+
next: `re-run, and do not touch the tests: ${list(env.map((f) => f.testId))}`,
|
|
36
|
+
why: 'every failure the goal depends on is the environment, so nothing has been shown about the code and changing a test would be guessing',
|
|
37
|
+
evidence: env.map((f) => `${f.testId}: ENV at ${f.confidence.toFixed(2)}`),
|
|
38
|
+
changesFiles: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const timing = seen.failing.filter((f) => f.category === 'TIMING');
|
|
42
|
+
if (timing.length > 0 || seen.flaky.length > 0) {
|
|
43
|
+
const worst = seen.flaky.slice().sort((a, b) => b.times - a.times);
|
|
44
|
+
return {
|
|
45
|
+
act: 'settle-flaky',
|
|
46
|
+
next: worst.length > 0 ? `settle ${worst[0].testId}, which has flaked ${worst[0].times} time(s)` : `settle ${list(timing.map((f) => f.testId))}`,
|
|
47
|
+
why: 'the goal is not broken, but the tests watching it come and go, so nobody can tell whether it is broken',
|
|
48
|
+
evidence: [...timing.map((f) => `${f.testId}: TIMING at ${f.confidence.toFixed(2)}`), ...worst.map((f) => `${f.testId}: flaked ${f.times} time(s)`)],
|
|
49
|
+
changesFiles: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Anything still failing has to be dealt with before anything else is spent on. Without this the
|
|
53
|
+
// policy read "3 failing" in its own state line and then said "nothing the goal depends on is
|
|
54
|
+
// failing", because undecided failures matched none of the branches above - the exact shape of
|
|
55
|
+
// error this project exists to refuse.
|
|
56
|
+
if (seen.failing.length > 0) {
|
|
57
|
+
return {
|
|
58
|
+
act: 'triage-undecided',
|
|
59
|
+
next: `put ${seen.failing.length} failing test(s) in front of a person: ${list(seen.failing.map((f) => f.testId))}`,
|
|
60
|
+
why: `the goal is failing and Tewip cannot say why: ${list([...new Set(seen.failing.map((f) => f.category))], 4)}. It will not move on to anything else while something it is watching is red`,
|
|
61
|
+
evidence: seen.failing.map((f) => `${f.testId}: ${f.category}${f.confidence ? ` at ${f.confidence.toFixed(2)}` : ' (undiagnosed)'}`),
|
|
62
|
+
changesFiles: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (seen.uncovered.length > 0) {
|
|
66
|
+
const first = seen.uncovered[0];
|
|
67
|
+
return {
|
|
68
|
+
act: 'cover-gap',
|
|
69
|
+
next: `write a test for ${first.id}: ${first.title}`,
|
|
70
|
+
why: `nothing the goal depends on is failing, so the next thing worth spending on is what nothing is checking - ${seen.uncovered.length} scenario(s), worst first`,
|
|
71
|
+
evidence: seen.uncovered.slice(0, 5).map((u) => `${u.id} (${u.priority || 'no priority'}): ${u.title}`),
|
|
72
|
+
changesFiles: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (seen.passing > 0) {
|
|
76
|
+
return {
|
|
77
|
+
act: 'attest',
|
|
78
|
+
next: 'produce the evidence bundle with `tewip attest`, and stop',
|
|
79
|
+
why: `everything the goal covers ran and passed (${seen.passing} test(s)), and nothing it depends on is unchecked. There is no next action worth taking`,
|
|
80
|
+
evidence: [`${seen.passing} test(s) passed in the last run`],
|
|
81
|
+
changesFiles: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
act: 'stand-down',
|
|
86
|
+
next: 'nothing: name the goal in words this project uses, or check the project is the right one',
|
|
87
|
+
why: `nothing kept here matches "${goal.slice(0, 60)}" - no test, no scenario, no failure - so acting would mean acting on something Tewip cannot see`,
|
|
88
|
+
evidence: [],
|
|
89
|
+
changesFiles: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `tewip pursue "<a goal>"`: what Tewip would do next about it, and why.
|
|
2
|
+
//
|
|
3
|
+
// By default it decides and explains, and does nothing. `--act` lets it carry out the one action
|
|
4
|
+
// it chose, and only through the gates that already prove a change before keeping it. There is no
|
|
5
|
+
// mode in which it loops unattended: an agent that keeps going until it decides to stop is the
|
|
6
|
+
// thing teams roll back, and nothing here needs it.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
|
|
10
|
+
import { decide } from './policy.js';
|
|
11
|
+
const words = (text) => (text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((w) => !['the', 'and', 'for', 'this', 'that', 'with', 'keep', 'green', 'sprint', 'flow', 'test', 'tests'].includes(w));
|
|
12
|
+
/** Does this test or scenario belong to the goal? Deterministic, so a person can predict it. */
|
|
13
|
+
function about(goal, text) {
|
|
14
|
+
if (goal.length === 0)
|
|
15
|
+
return true; // an unworded goal means the whole project
|
|
16
|
+
const haystack = text.toLowerCase();
|
|
17
|
+
return goal.some((w) => haystack.includes(w));
|
|
18
|
+
}
|
|
19
|
+
/** What is true about this project right now, as far as anything kept here can show. */
|
|
20
|
+
export function observe(root, goal, only) {
|
|
21
|
+
const dir = join(root, '.tewip', 'history');
|
|
22
|
+
const runs = [];
|
|
23
|
+
const records = [];
|
|
24
|
+
if (existsSync(dir)) {
|
|
25
|
+
for (const project of only ? [only] : readdirSync(dir)) {
|
|
26
|
+
const where = join(dir, project);
|
|
27
|
+
if (!existsSync(where))
|
|
28
|
+
continue;
|
|
29
|
+
runs.push(...readJsonl(datasetFiles(where).runs));
|
|
30
|
+
records.push(...readTriageRecords(datasetFiles(where).triage));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
runs.sort((a, b) => (a.finishedAt > b.finishedAt ? 1 : -1));
|
|
34
|
+
const newest = runs.at(-1);
|
|
35
|
+
const wanted = words(goal);
|
|
36
|
+
const failing = records
|
|
37
|
+
.filter((r) => r.runId === newest?.runId && about(wanted, r.testId))
|
|
38
|
+
.map((r) => {
|
|
39
|
+
const d = r.diagnosis;
|
|
40
|
+
return { testId: r.testId, category: String(d?.category ?? 'UNKNOWN'), confidence: Number(d?.confidence ?? 0) };
|
|
41
|
+
});
|
|
42
|
+
// A test that failed in more than one earlier run and is not failing now has come and gone.
|
|
43
|
+
const failedBefore = new Map();
|
|
44
|
+
for (const r of records)
|
|
45
|
+
if (about(wanted, r.testId))
|
|
46
|
+
failedBefore.set(r.testId, (failedBefore.get(r.testId) ?? 0) + 1);
|
|
47
|
+
const failingNow = new Set(failing.map((f) => f.testId));
|
|
48
|
+
const flaky = [...failedBefore.entries()].filter(([testId, times]) => times >= 2 && !failingNow.has(testId)).map(([testId, times]) => ({ testId, times }));
|
|
49
|
+
const attempted = Object.keys(newest?.attempts ?? {}).filter((t) => about(wanted, t));
|
|
50
|
+
return {
|
|
51
|
+
failing,
|
|
52
|
+
flaky,
|
|
53
|
+
uncovered: uncoveredScenarios(root, wanted, attempted),
|
|
54
|
+
passing: attempted.length - failing.length,
|
|
55
|
+
noHistory: runs.length === 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Scenarios of a kept plan that no attempted test can be matched to, worst priority first. */
|
|
59
|
+
function uncoveredScenarios(root, goal, attempted) {
|
|
60
|
+
const dir = join(root, '.tewip', 'plans');
|
|
61
|
+
if (!existsSync(dir))
|
|
62
|
+
return [];
|
|
63
|
+
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
66
|
+
try {
|
|
67
|
+
const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
68
|
+
const plan = saved.plan ?? saved;
|
|
69
|
+
if (!Array.isArray(plan.scenarios))
|
|
70
|
+
continue;
|
|
71
|
+
if (!about(goal, `${plan.title ?? ''} ${saved.feature ?? ''}`))
|
|
72
|
+
continue;
|
|
73
|
+
for (const scenario of plan.scenarios) {
|
|
74
|
+
const covered = (scenario.covered_by ?? '').trim() || attempted.find((t) => clean(t).includes(clean(scenario.id)) && clean(scenario.id).length >= 4);
|
|
75
|
+
if (!covered)
|
|
76
|
+
out.push({ id: scenario.id, title: scenario.title, priority: scenario.priority ?? '' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not a readable plan.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out.sort((a, b) => (a.priority || 'P9').localeCompare(b.priority || 'P9'));
|
|
84
|
+
}
|
|
85
|
+
export function renderPursuit(goal, seen, decision, acting) {
|
|
86
|
+
const lines = [
|
|
87
|
+
`# Goal: ${goal}`,
|
|
88
|
+
'',
|
|
89
|
+
`What is true now: ${seen.passing} passing, ${seen.failing.length} failing, ${seen.flaky.length} that come and go, ${seen.uncovered.length} unchecked.`,
|
|
90
|
+
'',
|
|
91
|
+
'## What Tewip would do next',
|
|
92
|
+
'',
|
|
93
|
+
`**${decision.next}**`,
|
|
94
|
+
'',
|
|
95
|
+
decision.why + '.',
|
|
96
|
+
'',
|
|
97
|
+
];
|
|
98
|
+
if (decision.evidence.length > 0) {
|
|
99
|
+
lines.push('What it is reacting to:', '');
|
|
100
|
+
for (const e of decision.evidence)
|
|
101
|
+
lines.push(`- ${e}`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
}
|
|
104
|
+
lines.push(decision.changesFiles
|
|
105
|
+
? acting
|
|
106
|
+
? 'This changes files, so it runs through the gate that proves a change before keeping it: anything unproved is thrown away.'
|
|
107
|
+
: 'This would change files, so Tewip has not done it. Run the command it names, or pass `--act`.'
|
|
108
|
+
: 'Tewip has not done anything: this is a decision, and the action is yours.', '', 'The order of these decisions is fixed and written down, not chosen by a model: a real bug first,', 'then the environment, then flakiness, then a coverage gap, and only then is a goal worth attesting.');
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
export function runPursue(cmd) {
|
|
112
|
+
const observed = observe(cmd.root, cmd.goal, cmd.name);
|
|
113
|
+
const decision = decide(cmd.goal, observed);
|
|
114
|
+
const report = renderPursuit(cmd.goal, observed, decision, Boolean(cmd.act));
|
|
115
|
+
console.log(report);
|
|
116
|
+
if (cmd.act && decision.changesFiles) {
|
|
117
|
+
console.error('\n`--act` is accepted, but Tewip carries out a change only through the command that proves it.');
|
|
118
|
+
console.error(`Run: ${decision.next}`);
|
|
119
|
+
}
|
|
120
|
+
if (cmd.out) {
|
|
121
|
+
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
122
|
+
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
return { decision, observed };
|
|
125
|
+
}
|
|
@@ -20,17 +20,24 @@ function kept(root, only) {
|
|
|
20
20
|
}
|
|
21
21
|
return { records, corrections };
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The report, without printing it. An MCP server speaks JSON-RPC over stdout, so anything one of
|
|
25
|
+
* its tools calls has to return its answer: a stray console.log corrupts every response the
|
|
26
|
+
* assistant receives, and the corruption looks like the tool being broken rather than noisy.
|
|
27
|
+
*/
|
|
28
|
+
export function recallReport(cmd) {
|
|
24
29
|
const { records, corrections } = kept(cmd.root, cmd.name);
|
|
25
30
|
if (records.length === 0) {
|
|
26
|
-
|
|
27
|
-
console.log(empty);
|
|
28
|
-
return empty;
|
|
31
|
+
return { report: 'Nothing has been kept here yet, so there is no history to search. Run `tewip explain <report>` first.', searched: 0, corrections: 0 };
|
|
29
32
|
}
|
|
30
33
|
const found = recall(cmd.question, memories(records, corrections), { testId: cmd.testId, limit: cmd.limit ?? 5 });
|
|
31
|
-
|
|
34
|
+
return { report: renderRecall(cmd.question, found), searched: records.length, corrections: corrections.length };
|
|
35
|
+
}
|
|
36
|
+
export function runRecall(cmd) {
|
|
37
|
+
const { report, searched, corrections } = recallReport(cmd);
|
|
32
38
|
console.log(report);
|
|
33
|
-
|
|
39
|
+
if (searched > 0)
|
|
40
|
+
console.error(`\nSearched ${searched} kept failure(s) and ${corrections} correction(s). No model was called.`);
|
|
34
41
|
if (cmd.out) {
|
|
35
42
|
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
36
43
|
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
@@ -163,10 +163,18 @@ export const INTERMITTENT_MIN_ALTERNATIONS = 3;
|
|
|
163
163
|
*/
|
|
164
164
|
export const timingIntermittent = {
|
|
165
165
|
id: 'timing-intermittent',
|
|
166
|
-
evaluate({ history, loadFailedRequests }) {
|
|
166
|
+
evaluate({ history, loadFailedRequests, run }) {
|
|
167
167
|
const { alternations, runs, failures } = history;
|
|
168
168
|
if (alternations < INTERMITTENT_MIN_ALTERNATIONS)
|
|
169
169
|
return null;
|
|
170
|
+
// Flipping only means flakiness if the code stayed the same while it flipped. Where no run
|
|
171
|
+
// carries a commit, nothing can tell "the test comes and goes" from "somebody kept changing
|
|
172
|
+
// the code" - which is exactly what happened when a regression was broken and restored several
|
|
173
|
+
// times in a row, and this rule called the regression flaky with the same confidence it gives
|
|
174
|
+
// a genuine flake. Without commits, that claim is not available.
|
|
175
|
+
if (!run.commitsKnown) {
|
|
176
|
+
return declined(this.id, `this test flipped ${alternations} times in ${runs} runs, but no run recorded a commit, so there is no way to tell a flaky test from code that kept changing`);
|
|
177
|
+
}
|
|
170
178
|
// A dependency that fails now and then also makes a test come and go, and that is ENV, not a
|
|
171
179
|
// race in the test. Seen on held-out data, where an external media API broke this test.
|
|
172
180
|
const failed = dependencyFailure(loadFailedRequests);
|
|
@@ -46,15 +46,18 @@ function history(root, only) {
|
|
|
46
46
|
runs.sort((a, b) => (a.finishedAt > b.finishedAt ? 1 : -1));
|
|
47
47
|
return { runs, records };
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/** The thread and its report, without printing: an MCP tool must return, never write to stdout. */
|
|
50
|
+
export function traceReport(cmd) {
|
|
50
51
|
const found = plans(cmd.root, cmd.only);
|
|
51
52
|
const { runs, records } = history(cmd.root, cmd.name);
|
|
52
53
|
const traced = found.map((plan) => tracePlan(plan, runs, records));
|
|
53
|
-
|
|
54
|
+
return { traced, report: renderTrace(traced), plans: found.length, runs: runs.length };
|
|
55
|
+
}
|
|
56
|
+
export function runTrace(cmd) {
|
|
57
|
+
const { traced, report, plans: found, runs } = traceReport(cmd);
|
|
54
58
|
console.log(report);
|
|
55
|
-
if (found
|
|
56
|
-
console.error(`\nTraced ${found
|
|
57
|
-
}
|
|
59
|
+
if (found > 0)
|
|
60
|
+
console.error(`\nTraced ${found} plan(s) against ${runs} kept run(s). No model was called.`);
|
|
58
61
|
if (cmd.out) {
|
|
59
62
|
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
60
63
|
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { isAbsolute } from 'node:path';
|
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { githubCiReport } from './github-report.js';
|
|
14
14
|
import { explainFailure, listDatasets, listFailedRuns, runView, windowView } from './tools.js';
|
|
15
|
-
const server = new McpServer({ name: 'tewip', version: '0.
|
|
15
|
+
const server = new McpServer({ name: 'tewip', version: '0.5.0' });
|
|
16
16
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
17
17
|
const dataset = z.string().optional().describe('Dataset directory relative to the Tewip repo, e.g. "data/real/gutenberg-holdout". Default: data/ (what the reporter collected). See list_datasets.');
|
|
18
18
|
const run = z.string().optional().describe('Run id or a unique part of it. Default: the latest run with failures.');
|
|
@@ -77,4 +77,50 @@ server.registerTool('test_plan', {
|
|
|
77
77
|
const { markdown, out } = await planProject({ feature, root: project, diffRange: diff, urls: urls ?? [] });
|
|
78
78
|
return `${markdown}\n\n(Saved to ${out}. To turn it into verified tests, run \`tewip write\` in that folder.)`;
|
|
79
79
|
}));
|
|
80
|
+
/**
|
|
81
|
+
* The project's own memory, its thread from requirement to run, and its evidence bundle. All three
|
|
82
|
+
* read what Tewip already kept for a project on this machine, call no model, and change nothing -
|
|
83
|
+
* which is what makes them safe to hand an assistant that may call them on its own.
|
|
84
|
+
*/
|
|
85
|
+
const project = z.string().describe('Absolute path of the project folder');
|
|
86
|
+
server.registerTool('recall', {
|
|
87
|
+
title: 'Has this happened here before?',
|
|
88
|
+
description: "What this project already concluded about a failure like this one: earlier failures that resemble it, what was decided, by which rule or which person, and the run each came from. Answers from records Tewip kept, never from a guess - and says plainly when nothing resembles it. Use before deciding what a failure means, and for questions like \"have we seen this error before?\".",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
question: z.string().describe('An error message, or a question in plain words'),
|
|
91
|
+
project,
|
|
92
|
+
test: z.string().optional().describe('Narrow to one test id, when the question is about a particular test'),
|
|
93
|
+
},
|
|
94
|
+
annotations: readOnly,
|
|
95
|
+
}, ({ question, project: root, test }) => answer(async () => {
|
|
96
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
97
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
98
|
+
const { recallReport } = await import('../engine/recall/recall-command.js');
|
|
99
|
+
return recallReport({ root, question, testId: test }).report;
|
|
100
|
+
}));
|
|
101
|
+
server.registerTool('trace_requirements', {
|
|
102
|
+
title: 'Is this requirement covered, and is it passing?',
|
|
103
|
+
description: 'Joins the plans Tewip wrote for a project to the tests that cover them and the runs that attempted them. A scenario counts as covered only when a real test can be named, and as passing only when a real run attempted it; everything else is reported as never run or not covered. Use for "are we ready to release?" and "what is not tested?".',
|
|
104
|
+
inputSchema: { project, only: z.string().optional().describe('Only plans whose title, ticket or feature contains this') },
|
|
105
|
+
annotations: readOnly,
|
|
106
|
+
}, ({ project: root, only }) => answer(async () => {
|
|
107
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
108
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
109
|
+
const { traceReport } = await import('../engine/trace/trace-command.js');
|
|
110
|
+
return traceReport({ root, only }).report;
|
|
111
|
+
}));
|
|
112
|
+
server.registerTool('next_action', {
|
|
113
|
+
title: 'What should be done next about a goal?',
|
|
114
|
+
description: 'Given a goal in plain words - "keep the checkout flow green" - says what Tewip would do next and why, from what it can actually see in the project. The order is fixed and published: a real bug first, then the environment, then flakiness, then anything failing it cannot explain, then a coverage gap. It decides only; it never changes a file.',
|
|
115
|
+
inputSchema: { goal: z.string().describe('The goal, in plain words'), project },
|
|
116
|
+
annotations: readOnly,
|
|
117
|
+
}, ({ goal, project: root }) => answer(async () => {
|
|
118
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
119
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
120
|
+
// observe/decide/render are pure; runPursue prints, which would corrupt the protocol.
|
|
121
|
+
const { observe, renderPursuit } = await import('../engine/pursue/pursue-command.js');
|
|
122
|
+
const { decide } = await import('../engine/pursue/policy.js');
|
|
123
|
+
const observed = observe(root, goal);
|
|
124
|
+
return renderPursuit(goal, observed, decide(goal, observed), false);
|
|
125
|
+
}));
|
|
80
126
|
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-failures",
|