tewip-ai 0.23.0 → 0.25.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 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` (see [editor setup](#use-it-from-your-editor-any-ide-that-speaks-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.
@@ -440,6 +444,80 @@ Every change is small (1 to 16 lines, median 4) and most are what the developers
440
444
  **No wrong fix was offered**: anything that could not be proved was thrown away and the file put
441
445
  back byte for byte.
442
446
 
447
+ ## One bundle a release manager can read (Phase 15)
448
+
449
+ ```bash
450
+ tewip attest --out attestation.md
451
+ ```
452
+
453
+ Everything Tewip knows about a build, in one file: which tests ran, what each failure was, which
454
+ rule or which model decided it, **what it refused to decide**, and what it never looked at. The
455
+ refusals are the point - a tool that never says "I could not tell" has nothing worth attesting,
456
+ because every line it writes might be a guess.
457
+
458
+ The bundle carries a digest, and an Ed25519 signature when you give it a key:
459
+
460
+ ```bash
461
+ TEWIP_ATTEST_KEY=./private.pem tewip attest # sign it
462
+ tewip attest --verify attestation.json --key public.pem # check it
463
+ ```
464
+
465
+ A digest is not a signature and Tewip says so: without a key it tells you the bundle is undamaged,
466
+ not that nobody edited it. Editing a claim out of a signed bundle makes verification fail and exit
467
+ non-zero. Every CI run writes one of these automatically.
468
+
469
+ ## Is this incident covered by a test? (Phase 16)
470
+
471
+ ```bash
472
+ tewip incident sentry-issue.json
473
+ ```
474
+
475
+ Reads a Sentry payload, an OpenTelemetry span or a plain stack trace, finds the test that should
476
+ have caught it, and answers with one of four verdicts: **reproduced**, **passed** (the test ran and
477
+ did not catch it, which is worse news), **failed differently**, or **never ran**. It does not say
478
+ "probably covered".
479
+
480
+ ## What did we decide about this last time? (Phase 17)
481
+
482
+ ```bash
483
+ tewip recall "the login test that keeps flaking"
484
+ ```
485
+
486
+ Every correction your team ever gave Tewip is kept. Ask, and it answers with your own decision from
487
+ then, and says it was yours - not its. It never acts on a correction by itself.
488
+
489
+ ## From the ticket to the run (Phase 18)
490
+
491
+ ```bash
492
+ tewip trace --only "SHOP-12"
493
+ ```
494
+
495
+ Joins the test plans Tewip wrote to the tests that cover them and the runs that attempted them. A
496
+ scenario counts as covered only when a real test can be named, and passing only when a real run
497
+ attempted it; everything else says what is missing. No model, no cost.
498
+
499
+ ## A goal instead of a verb (Phases 19 and 21)
500
+
501
+ ```bash
502
+ tewip pursue "keep the checkout flow green"
503
+ tewip pursue "keep the checkout flow green" --act
504
+ ```
505
+
506
+ Give Tewip a goal and it says what it would do next, and why, from what it can actually see. The
507
+ order is fixed, published and testable - **not chosen by a model**: a real bug first, then the
508
+ environment, then flakiness, then anything failing it cannot explain, then a coverage gap, and only
509
+ then is the goal worth attesting. Deciding the same state twice gives the same answer.
510
+
511
+ With `--act` it carries the decision out, and this is where the refusals matter most:
512
+
513
+ - It **never does the work itself.** Each action is an existing command that already proves what it
514
+ changes - `write` keeps a test only when it passes *and* fails when broken.
515
+ - **Two actions are on the allowlist**: write a missing test, and attest a green goal. Everything
516
+ else is refused by name. It will not work around a real bug.
517
+ - **One action, then stop.** There is no unattended loop.
518
+ - Everything it did or refused goes to `.tewip/actions.jsonl`, with the run that justified it.
519
+ - It **will not repeat itself**: the same attempt is refused until a new run gives it new evidence.
520
+
443
521
  ## Where it has been tried
444
522
 
445
523
  | Project | How | Result |
package/dist/cli/help.js CHANGED
@@ -105,8 +105,11 @@ Usage:
105
105
  green" - and it says what it would do next and why, from what
106
106
  it can actually see. The order is fixed and written down: a
107
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.
108
+ coverage gap. It decides; it does not act unless you ask. With
109
+ --act it carries out the two safe ones - writing a missing test,
110
+ or attesting a green goal - by running the command that already
111
+ proves them. It will not touch a real bug, and it writes what it
112
+ did or refused to .tewip/actions.jsonl.
110
113
  tewip trace [--only "<ticket or title>"] [--out trace.md]
111
114
  Is this requirement covered, and is it passing right now? Joins
112
115
  the plans Tewip wrote to the tests that cover them and the runs
@@ -125,10 +128,14 @@ Usage:
125
128
  it fails for the incident's own reason. A test that passes, or
126
129
  fails its own way, is reported and thrown away.
127
130
  tewip attest [--run <id>] [--out attestation.md] [--json bundle.json]
131
+ tewip attest --verify <bundle.json> [--key <public.pem>]
128
132
  What Tewip checked for a build, as one artifact: the claims with
129
133
  their evidence and which layer decided each, what it refused to
130
134
  decide and why, and what it did not check at all. Nothing enters
131
- it that Tewip did not verify itself. No model, no cost.
135
+ it that Tewip did not verify itself. Every bundle carries a
136
+ digest; set TEWIP_ATTEST_KEY to an ed25519 private key to sign
137
+ it too. --verify says what it checked and what that does not
138
+ establish - a digest means undamaged, not unedited. No cost.
132
139
  tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
133
140
  tewip --version Print the version.
134
141
 
package/dist/cli/tewip.js CHANGED
@@ -210,6 +210,15 @@ async function main() {
210
210
  return;
211
211
  }
212
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
+ }
213
222
  const { runAttest } = await import('../engine/attest/attest-command.js');
214
223
  runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
215
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');
@@ -23,9 +46,18 @@ function history(root, only) {
23
46
  function whatWasChecked(records) {
24
47
  const checked = [];
25
48
  const notChecked = [];
26
- checked.push('every failing test in the run was read, and each was given a cause or left undecided');
27
- const byRules = records.filter((r) => r.diagnosis?.source === 'rules').length;
28
- const byModel = records.filter((r) => r.diagnosis?.source === 'llm').length;
49
+ // Decided means settled: a rule that looked and could not say is a refusal, and counting it here
50
+ // would have a bundle claim more decisions than it lists claims.
51
+ const settledBy = (source) => records.filter((r) => {
52
+ const d = r.diagnosis;
53
+ return d?.source === source && d.category && d.category !== 'UNKNOWN';
54
+ }).length;
55
+ if (records.length === 0)
56
+ checked.push('every test in the run passed, so there was nothing to diagnose');
57
+ else
58
+ checked.push('every failing test in the run was read, and each was given a cause or left undecided');
59
+ const byRules = settledBy('rules');
60
+ const byModel = settledBy('llm');
29
61
  if (byRules > 0)
30
62
  checked.push(`${byRules} decided by deterministic rules (${ENGINE_VERSION}), which call no model and give the same answer every time`);
31
63
  if (byModel > 0)
@@ -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
- return {
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.`, '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.');
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
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What Tewip may carry out, and with what. Anything absent is refused: reporting a real bug, for
3
+ * instance, is deliberately not an action - it is for a person to read.
4
+ */
5
+ export function planFor(decision) {
6
+ switch (decision.act) {
7
+ case 'cover-gap': {
8
+ // `write` drafts the test, runs it, breaks it on purpose to see it fail, and keeps it only
9
+ // if both hold. The scenario id is in the decision's own next step.
10
+ const id = /write a test for ([\w-]+)/.exec(decision.next)?.[1];
11
+ return id ? { command: ['write', '--only', id], target: `scenario ${id}` } : null;
12
+ }
13
+ case 'attest':
14
+ return { command: ['attest'], target: 'the evidence bundle' };
15
+ default:
16
+ return null;
17
+ }
18
+ }
19
+ /** Why an action was not carried out, in words a person can act on. */
20
+ export const cannotAct = {
21
+ 'look-first': 'there is nothing to act on until a report has been read',
22
+ 'triage-real-bug': 'a real bug is for a person to see, and Tewip will not work around one on its own',
23
+ 'rerun-environment': 'a re-run belongs to whatever runs the tests, not to Tewip',
24
+ 'settle-flaky': 'settling a flaky test changes a test file, which Tewip does only when asked directly with `tewip fix`',
25
+ 'triage-undecided': 'Tewip cannot say why these failed, so it has nothing it could safely do about them',
26
+ 'cover-gap': '',
27
+ attest: '',
28
+ 'stand-down': 'there is nothing here that matches the goal',
29
+ };
30
+ /**
31
+ * Has this exact action already been tried against this exact evidence? A scheduled run that keeps
32
+ * attempting an impossible task is the failure mode that makes people turn an agent off. A new run
33
+ * is new evidence and unblocks it; running again over the same report does not.
34
+ *
35
+ * Refusals are not counted: refusing costs nothing and says nothing about whether it would work.
36
+ */
37
+ export function alreadyTried(records, act, target, runId) {
38
+ return records.find((r) => r.act === act && r.target === target && r.runId === runId && r.outcome !== 'refused') ?? null;
39
+ }
@@ -4,9 +4,11 @@
4
4
  // it chose, and only through the gates that already prove a change before keeping it. There is no
5
5
  // mode in which it loops unattended: an agent that keeps going until it decides to stop is the
6
6
  // thing teams roll back, and nothing here needs it.
7
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
8
9
  import { dirname, join, resolve } from 'node:path';
9
10
  import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
11
+ import { alreadyTried, cannotAct, planFor } from './act.js';
10
12
  import { decide } from './policy.js';
11
13
  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
14
  /** Does this test or scenario belong to the goal? Deterministic, so a person can predict it. */
@@ -53,6 +55,7 @@ export function observe(root, goal, only) {
53
55
  uncovered: uncoveredScenarios(root, wanted, attempted),
54
56
  passing: attempted.length - failing.length,
55
57
  noHistory: runs.length === 0,
58
+ runId: newest?.runId ?? '',
56
59
  };
57
60
  }
58
61
  /** Scenarios of a kept plan that no attempted test can be matched to, worst priority first. */
@@ -108,15 +111,100 @@ export function renderPursuit(goal, seen, decision, acting) {
108
111
  : '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
112
  return lines.join('\n');
110
113
  }
114
+ /** The audit file the team owns: every action Tewip took on its own, and every one it refused. */
115
+ const auditPath = (root) => join(root, '.tewip', 'actions.jsonl');
116
+ export function actionsTaken(root) {
117
+ try {
118
+ return readFileSync(auditPath(root), 'utf8')
119
+ .split(String.fromCharCode(10))
120
+ .filter(Boolean)
121
+ .map((l) => JSON.parse(l));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ function record(root, entry) {
128
+ mkdirSync(dirname(auditPath(root)), { recursive: true });
129
+ appendFileSync(auditPath(root), JSON.stringify(entry) + String.fromCharCode(10), 'utf8');
130
+ }
131
+ /**
132
+ * The Tewip command line belonging to *this* build, found from this file rather than from how the
133
+ * process was started.
134
+ *
135
+ * This is not a detail. The first version used process.argv[1], which is the command line only when
136
+ * a person typed it: under a test runner argv[1] is the test file, so acting re-ran the test file,
137
+ * which acted again, which re-ran it - a fork bomb that froze the machine it was written on. What
138
+ * re-enters Tewip has to be Tewip, established here, or nothing is carried out at all.
139
+ */
140
+ export function cliEntry() {
141
+ const here = import.meta.dirname;
142
+ for (const candidate of [join(here, '..', '..', 'cli', 'tewip.ts'), join(here, '..', '..', 'cli', 'tewip.js')]) {
143
+ if (existsSync(candidate))
144
+ return candidate;
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * Carries out the one action that was decided, by running the command that already proves it.
150
+ * Tewip does not do the work here; it asks the gate to.
151
+ */
152
+ function carryOut(cmd, decision, observed) {
153
+ const plan = planFor(decision);
154
+ const runId = observed.runId ?? '';
155
+ const note = (outcome, detail, target = plan?.target ?? '') => ({
156
+ at: new Date().toISOString(),
157
+ goal: cmd.goal,
158
+ act: decision.act,
159
+ target,
160
+ command: plan ? 'tewip ' + plan.command.join(' ') : '',
161
+ runId,
162
+ outcome,
163
+ detail,
164
+ });
165
+ if (!plan) {
166
+ const why = cannotAct[decision.act] || 'this is not something Tewip carries out on its own';
167
+ record(cmd.root, note('refused', why, decision.act));
168
+ return 'Not carried out: ' + why + '.';
169
+ }
170
+ const tried = alreadyTried(actionsTaken(cmd.root), decision.act, plan.target, runId);
171
+ if (tried) {
172
+ record(cmd.root, note('refused', 'already attempted at ' + tried.at + ', on the same evidence'));
173
+ return ['Not carried out: Tewip already attempted this at ' + tried.at.slice(0, 19).replace('T', ' ') + ' UTC, and nothing has happened since that would make the outcome different.', '', 'That attempt ' + (tried.outcome === 'done' ? 'succeeded' : 'did not finish') + ': ' + tried.detail, '', 'Run ' + tried.command + ' yourself if you want it tried again.'].join(String.fromCharCode(10));
174
+ }
175
+ // Re-enter this same build, so an installed Tewip runs the installed gates and a checkout runs
176
+ // the checkout's - never a different version from the one that decided, and never whatever
177
+ // happens to have started this process.
178
+ const entry = cliEntry();
179
+ if (!entry) {
180
+ record(cmd.root, note('refused', 'the Tewip command line could not be found next to this build, so there is nothing safe to re-enter'));
181
+ return 'Not carried out: Tewip could not find its own command line beside this build, and it will not guess at one. Run ' + 'tewip ' + plan.command.join(' ') + ' yourself.';
182
+ }
183
+ const ran = spawnSync(process.execPath, [entry, ...plan.command], { cwd: cmd.root, encoding: 'utf8', timeout: 20 * 60_000, maxBuffer: 32 * 1024 * 1024 });
184
+ const output = ((ran.stdout ?? '') + (ran.stderr ?? '')).trim();
185
+ const ok = ran.status === 0;
186
+ // What the audit file remembers: the headline of a success, the tail of a failure. A failure's
187
+ // reason is at the end of what it printed; a success's is at the start.
188
+ const lines = output.split(String.fromCharCode(10)).map((l) => l.trim()).filter(Boolean);
189
+ const detail = (ok ? lines[0] ?? 'it printed nothing' : lines.slice(-3).join(' ')).slice(0, 300);
190
+ record(cmd.root, note(ok ? 'done' : 'failed', detail));
191
+ return [
192
+ ok ? 'Carried out: tewip ' + plan.command.join(' ') : 'Tried and did not finish: tewip ' + plan.command.join(' '),
193
+ '',
194
+ output.slice(-2000),
195
+ '',
196
+ ok
197
+ ? 'Anything kept by that command was proved by it; anything unproved was thrown away. Written to .tewip/actions.jsonl.'
198
+ : 'Nothing was kept. Written to .tewip/actions.jsonl.',
199
+ ].join(String.fromCharCode(10));
200
+ }
111
201
  export function runPursue(cmd) {
112
202
  const observed = observe(cmd.root, cmd.goal, cmd.name);
113
203
  const decision = decide(cmd.goal, observed);
114
204
  const report = renderPursuit(cmd.goal, observed, decision, Boolean(cmd.act));
115
205
  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
- }
206
+ if (cmd.act)
207
+ console.log(String.fromCharCode(10) + '---' + String.fromCharCode(10) + carryOut(cmd, decision, observed));
120
208
  if (cmd.out) {
121
209
  mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
122
210
  writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
@@ -20,17 +20,24 @@ function kept(root, only) {
20
20
  }
21
21
  return { records, corrections };
22
22
  }
23
- export function runRecall(cmd) {
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
- const empty = 'Nothing has been kept here yet, so there is no history to search. Run `tewip explain <report>` first.';
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
- const report = renderRecall(cmd.question, found);
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
- console.error(`\nSearched ${records.length} kept failure(s) and ${corrections.length} correction(s). No model was called.`);
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');
@@ -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
- export function runTrace(cmd) {
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
- const report = renderTrace(traced);
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.length > 0) {
56
- console.error(`\nTraced ${found.length} plan(s) against ${runs.length} kept run(s). No model was called.`);
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');
@@ -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.4.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.23.0",
3
+ "version": "0.25.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",