tewip-ai 0.39.0 → 0.41.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
@@ -576,6 +576,40 @@ With `--json` the verdict is **sealed**: editing `not-ready` to `ready` in the f
576
576
  fail, so a verdict that travels into a ticket or a release note can be checked rather than trusted. It is
577
577
  also an MCP tool, so an agent can ask whether its own work is done before saying that it is.
578
578
 
579
+ ## Tewip as a peer other agents must satisfy (Phase 34, new)
580
+
581
+ ```bash
582
+ tewip a2a # serves on 127.0.0.1:41241
583
+ ```
584
+
585
+ MCP lets an assistant call Tewip as a tool. **A2A is the other direction**: Tewip as an agent in its
586
+ own right, which another agent has to get past before its work counts as finished. That is the right
587
+ shape for a tool whose whole claim is proving work *written by a person or by an agent* — when most
588
+ code is written by agents, the thing that checks it has to be reachable by agents.
589
+
590
+ It serves the card at `/.well-known/agent-card.json` and answers JSON-RPC `SendMessage`, with eight
591
+ skills: triage, vouch, pick, schema, access, perf, standards and done.
592
+
593
+ **It asks rather than guesses.** Send "check my code" and you get back a question naming the eight
594
+ things Tewip answers — A2A has a state for exactly that, `TASK_STATE_INPUT_REQUIRED`:
595
+
596
+ ```
597
+ TASK_STATE_INPUT_REQUIRED
598
+ Tewip will not guess which question you are asking: that could be "Does this keep the
599
+ team's own rules?" or "Is this work actually finished?", and Tewip will not pick for you.
600
+ ```
601
+
602
+ **The guardrails are the design.** This is an HTTP port that runs commands against your repository:
603
+
604
+ - **Only read-only skills are reachable.** `fix`, `write`, `build`, `pursue` and `prove` are not on
605
+ the list — they change files or run your suite. A test asserts they never can be.
606
+ - **It listens on this machine alone** unless you pass `--host`, and says plainly what that means.
607
+ - **Every request is recorded** in `.tewip/a2a.jsonl`.
608
+
609
+ Its card declares `streaming: false` and `pushNotifications: false`, and those operations return an
610
+ error naming the field that says why. Declaring a capability you have not implemented is the one
611
+ thing an agent card must not do.
612
+
579
613
  ## Is this change slower? (Phase 30, new)
580
614
 
581
615
  ```bash
@@ -836,7 +870,8 @@ That last section is the part no SQL linter does: it searches your own code for
836
870
  away. "This drops `users.nickname`, and four files still read it" is a different sentence from "dropping a
837
871
  column is risky", and it is the one that stops the outage.
838
872
 
839
- It reads **SQL, Django, Rails and Alembic** migrations, judges **only the forward path** (a rollback step
873
+ It reads **SQL, Django, Rails, Alembic and TypeORM** migrations — which covers Prisma, Drizzle, Flyway
874
+ and golang-migrate too, since those all emit plain `.sql`. It judges **only the forward path** (a rollback step
840
875
  contains the mirror image of the change, so reading it says a migration that creates a table destroys
841
876
  one), and stays quiet when the team already did the safe thing — `atomic = False`, `algorithm:
842
877
  :concurrently`, `NOT VALID`, `postgresql_concurrently=True`. When it cannot read a migration it says so
package/dist/cli/help.js CHANGED
@@ -212,6 +212,14 @@ Usage:
212
212
  it too. --verify says what it checked and what that does not
213
213
  establish - a digest means undamaged, not unedited. No cost.
214
214
  tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
215
+ tewip a2a [--port 41241] [--host 127.0.0.1]
216
+ Answer other agents over A2A, so an agent that writes a diff has
217
+ a peer it must satisfy. Serves the agent card at
218
+ /.well-known/agent-card.json and takes JSON-RPC "SendMessage".
219
+ Only the read-only skills are reachable - nothing it exposes
220
+ writes a file - it listens on this machine alone unless you pass
221
+ --host, and every request is recorded in .tewip/a2a.jsonl. A
222
+ message it cannot place comes back as a question, not a guess.
215
223
  tewip --version Print the version.
216
224
 
217
225
  Reports it reads: Playwright, pytest/unittest, Jest, Vitest, Mocha, node:test, JUnit/TestNG
@@ -20,6 +20,19 @@ export async function proveVerb(verb, args) {
20
20
  });
21
21
  return true;
22
22
  }
23
+ case 'a2a': {
24
+ const { serveA2A } = await import('../engine/a2a/serve.js');
25
+ const { dirname, join } = await import('node:path');
26
+ serveA2A({
27
+ root: process.cwd(),
28
+ port: Number(flag(args, 'port') ?? '41241'),
29
+ // Loopback unless somebody asks for otherwise in so many words. The server runs commands
30
+ // against a repository and has no authentication, so the default must be the safe one.
31
+ host: flag(args, 'host') ?? '127.0.0.1',
32
+ tewipRoot: join(dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'),
33
+ });
34
+ return true;
35
+ }
23
36
  case 'perf': {
24
37
  const { runPerf } = await import('../engine/perf/perf-command.js');
25
38
  const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label'].includes(args[i - 1] ?? ''));
@@ -0,0 +1,125 @@
1
+ // Phase 34: turning another agent's message into one of Tewip's verdicts.
2
+ //
3
+ // The interesting decision here is what to do when the message is vague, and the answer is the same
4
+ // one the rest of this project gives: ask rather than guess. A2A has a state for exactly that -
5
+ // TASK_STATE_INPUT_REQUIRED - so a caller who says "check my code" gets back a question naming the
6
+ // skills Tewip has, not a guess at which of eight things they meant. Guessing would make Tewip
7
+ // occasionally run a twenty-minute job nobody asked for, and occasionally answer the wrong question
8
+ // confidently, which is the failure this whole product exists to avoid.
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { ANSWERABLE, skillById } from './card.js';
13
+ const str = (v) => (typeof v === 'string' ? v : '');
14
+ /** The words in a message, from its text parts. A2A puts the text directly on the part. */
15
+ export function textOf(message) {
16
+ if (!Array.isArray(message.parts))
17
+ return '';
18
+ return message.parts
19
+ .map((p) => (p && typeof p === 'object' ? str(p.text) : ''))
20
+ .filter((t) => t !== '')
21
+ .join(' ')
22
+ .trim();
23
+ }
24
+ /** A skill named outright, either in the message metadata or in a data part. */
25
+ function namedSkill(message) {
26
+ const meta = (message.metadata ?? {});
27
+ const fromMeta = str(meta.skillId ?? meta.skill);
28
+ if (fromMeta !== '')
29
+ return fromMeta;
30
+ if (!Array.isArray(message.parts))
31
+ return '';
32
+ for (const part of message.parts) {
33
+ if (!part || typeof part !== 'object')
34
+ continue;
35
+ const data = part.data;
36
+ if (data && typeof data === 'object') {
37
+ const named = str(data.skillId ?? data.skill);
38
+ if (named !== '')
39
+ return named;
40
+ }
41
+ }
42
+ return '';
43
+ }
44
+ /** How well a skill matches some words: its own tags and name, scored by how specific the word is. */
45
+ function scoreOf(skill, words) {
46
+ const haystack = `${skill.id} ${skill.verb} ${skill.name} ${skill.tags.join(' ')}`.toLowerCase();
47
+ let score = 0;
48
+ for (const word of words) {
49
+ if (word.length < 4)
50
+ continue;
51
+ if (haystack.includes(word))
52
+ score += word.length >= 7 ? 2 : 1;
53
+ }
54
+ return score;
55
+ }
56
+ /**
57
+ * Which skill a message is asking for.
58
+ *
59
+ * An id given outright always wins, because a calling agent that read the card should be taken at
60
+ * its word. Otherwise the words are matched, and a tie or a blank is a question rather than a coin
61
+ * toss: two skills scoring the same means Tewip genuinely does not know which was meant.
62
+ */
63
+ export function chooseSkill(message) {
64
+ const asked = textOf(message);
65
+ const named = namedSkill(message);
66
+ if (named !== '') {
67
+ const skill = skillById(named);
68
+ if (skill)
69
+ return { kind: 'skill', skill, text: asked };
70
+ return { kind: 'unknown', asked: named };
71
+ }
72
+ const words = asked.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
73
+ const scored = ANSWERABLE.map((skill) => ({ skill, score: scoreOf(skill, words) })).sort((a, b) => b.score - a.score);
74
+ const [best, next] = scored;
75
+ if (!best || best.score === 0) {
76
+ return { kind: 'ask', why: 'nothing in that message names one of the things Tewip answers' };
77
+ }
78
+ if (next && next.score === best.score) {
79
+ return { kind: 'ask', why: `that could be "${best.skill.name}" or "${next.skill.name}", and Tewip will not pick for you` };
80
+ }
81
+ return { kind: 'skill', skill: best.skill, text: asked };
82
+ }
83
+ /** The question Tewip asks back when it will not guess, listing what it can actually answer. */
84
+ export function askWhich(why) {
85
+ const list = ANSWERABLE.map((s) => `- \`${s.id}\` — ${s.name}`).join('\n');
86
+ return [
87
+ `Tewip will not guess which question you are asking: ${why}.`,
88
+ '',
89
+ 'Send the skill id in the message metadata as `skillId`, or say which of these you want:',
90
+ '',
91
+ list,
92
+ ].join('\n');
93
+ }
94
+ export function cliEntry() {
95
+ const here = import.meta.dirname;
96
+ for (const candidate of [join(here, '..', '..', 'cli', 'tewip.ts'), join(here, '..', '..', 'cli', 'tewip.js')]) {
97
+ if (existsSync(candidate))
98
+ return candidate;
99
+ }
100
+ return null;
101
+ }
102
+ /**
103
+ * Runs the verb and returns what it printed.
104
+ *
105
+ * The command line is the gate every one of these verdicts already goes through, so A2A reuses it
106
+ * rather than reaching into the engine: one code path means a verdict delivered to an agent cannot
107
+ * quietly differ from the same verdict delivered to a person.
108
+ */
109
+ export function runSkill(root, skill, args, timeoutMs = 10 * 60_000) {
110
+ const entry = cliEntry();
111
+ if (entry === null)
112
+ return { text: 'Tewip could not find its own command line, so it cannot answer.', failed: true };
113
+ const ran = spawnSync(process.execPath, [entry, skill.verb, ...args], {
114
+ cwd: root,
115
+ encoding: 'utf8',
116
+ timeout: timeoutMs,
117
+ maxBuffer: 32 * 1024 * 1024,
118
+ });
119
+ if (ran.error)
120
+ return { text: `Tewip could not run \`${skill.verb}\`: ${ran.error.message}`, failed: true };
121
+ const out = `${ran.stdout ?? ''}${ran.stderr ? `\n${ran.stderr}` : ''}`.trim();
122
+ // A non-zero exit is how the gating verbs report a real problem, so it is an answer rather than a
123
+ // failure. Only an empty answer means something actually went wrong.
124
+ return { text: out === '' ? `\`${skill.verb}\` produced no output.` : out, failed: out === '' };
125
+ }
@@ -0,0 +1,126 @@
1
+ // Phase 34: the agent card, so other agents can find out what Tewip will answer.
2
+ //
3
+ // A2A is the one capability on the standard list that matches this product's thesis exactly. Tewip
4
+ // exists to prove work "written by a person or by an agent", and MCP already lets an assistant call
5
+ // it as a tool. A2A is the other direction: Tewip as a peer that another agent has to satisfy
6
+ // before its diff counts as finished. When most code is written by agents, the thing that checks
7
+ // the work has to be reachable by agents.
8
+ //
9
+ // Every field here was taken from the v1.0 specification rather than from memory, because four
10
+ // details would have been wrong: discovery is `/.well-known/agent-card.json` and not `agent.json`;
11
+ // the JSON-RPC method is `SendMessage` in PascalCase, not `message/send`; a role is `ROLE_USER`,
12
+ // not `user`; and a task state is `TASK_STATE_COMPLETED`, not `completed`. Each of those is the
13
+ // kind of mistake that produces a server nothing can talk to.
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /**
17
+ * The verbs a caller down a socket is allowed to reach, and the single rule behind the list:
18
+ * **everything here reads and nothing here writes.**
19
+ *
20
+ * `fix`, `write`, `build` and `pursue` change files; `prove` deliberately breaks a file and runs the
21
+ * suite before putting it back. None of those belongs behind an HTTP port that another program can
22
+ * reach. The whole guardrail story in this project is an allowlist of what may be done rather than
23
+ * a blocklist of what may not, and this is the same allowlist applied to a new door.
24
+ */
25
+ export const ANSWERABLE = [
26
+ {
27
+ id: 'explain-failures',
28
+ verb: 'explain',
29
+ name: 'Why did these tests fail?',
30
+ description: 'Reads a test report and gives each failure a cause with the evidence for it: a real bug, a flaky test, the environment, the test data, or a changed selector. Answers "cannot say" rather than guessing.',
31
+ tags: ['testing', 'triage', 'ci', 'debugging'],
32
+ examples: ['Why did the checkout tests fail in this report?', 'Is this failure flaky or a real bug?'],
33
+ },
34
+ {
35
+ id: 'could-these-tests-fail',
36
+ verb: 'vouch',
37
+ name: 'Could these tests ever fail?',
38
+ description: 'Reads a test file and names the tests that check nothing: an assertion that compares a value with itself, an expectation left unfinished, a test with no assertion at all. A suite that cannot fail is not evidence.',
39
+ tags: ['testing', 'quality', 'review'],
40
+ examples: ['Can the tests in this file actually fail?'],
41
+ },
42
+ {
43
+ id: 'which-tests-notice',
44
+ verb: 'pick',
45
+ name: 'Which tests could notice this change?',
46
+ description: 'Narrows a test run from facts in the source, and refuses to narrow when it cannot show the rest are unaffected. Returns the files to run, or says it cannot be done safely.',
47
+ tags: ['testing', 'ci', 'performance'],
48
+ examples: ['Which tests do I need to run for this diff?'],
49
+ },
50
+ {
51
+ id: 'migration-safety',
52
+ verb: 'schema',
53
+ name: 'What will this migration do to a live database?',
54
+ description: 'Names what breaks the version still running during a rolling deploy, destroys data, blocks writes, or cannot be undone - and searches the repository for code that still uses a column being dropped. Reads SQL, Django, Rails, Alembic and TypeORM, which covers Prisma, Drizzle and Flyway.',
55
+ tags: ['database', 'migrations', 'deployment', 'risk'],
56
+ examples: ['Is this migration safe to deploy?', 'Will this drop break the running version?'],
57
+ },
58
+ {
59
+ id: 'accessibility',
60
+ verb: 'access',
61
+ name: 'Can everybody use this interface?',
62
+ description: 'Names the accessibility barriers that reading the markup proves, each against its WCAG 2.2 criterion and EN 301 549 clause, and keeps apart the ones only a person can settle. Always reports which criteria it could not check.',
63
+ tags: ['accessibility', 'wcag', 'compliance', 'frontend'],
64
+ examples: ['Is this component accessible?', 'What WCAG failures are in this page?'],
65
+ },
66
+ {
67
+ id: 'is-it-slower',
68
+ verb: 'perf',
69
+ name: 'Is this change slower?',
70
+ description: 'Judges a k6 or Lighthouse run against recorded baselines, and refuses to call a regression without at least three of them, because one run against one run cannot tell a change from a busy machine.',
71
+ tags: ['performance', 'benchmarking', 'regression'],
72
+ examples: ['Did this change make the checkout slower?'],
73
+ },
74
+ {
75
+ id: 'house-standards',
76
+ verb: 'standards',
77
+ name: "Does this keep the team's own rules?",
78
+ description: "Checks a change against the rules this team wrote down in .tewip/standards/, quoting the offending line and the team's own reason. Rules it enforces are kept apart from guidance it can only carry.",
79
+ tags: ['code-review', 'standards', 'conventions'],
80
+ examples: ['Does this change follow our conventions?'],
81
+ },
82
+ {
83
+ id: 'is-this-done',
84
+ verb: 'done',
85
+ name: 'Is this work actually finished?',
86
+ description: 'One verdict over a change, from layers that each have a measured accuracy: is anything failing, is the changed code tested by something that really reaches it, can those tests fail at all, and is what was asked for covered. Answers ready, not ready, or cannot say - and never rounds cannot say up to ready.',
87
+ tags: ['code-review', 'quality', 'verification'],
88
+ examples: ['Is this pull request ready to merge?', 'Is my change done?'],
89
+ },
90
+ ];
91
+ /** The version Tewip is running, so the card does not claim a version it is not. */
92
+ function versionOf(root) {
93
+ try {
94
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
95
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
96
+ }
97
+ catch {
98
+ return '0.0.0';
99
+ }
100
+ }
101
+ /**
102
+ * The card, built from the same list the server answers from, so the two cannot drift. A card that
103
+ * advertises a skill the server will not perform is worse than no card.
104
+ */
105
+ export function agentCard(url, tewipRoot) {
106
+ return {
107
+ name: 'Tewip',
108
+ description: 'Proves software work, written by a person or by an agent. Triages a failing test to a cause with evidence, says whether a test could ever fail, reads what a migration will do to a live database, names who cannot use an interface, and gives one verdict on whether a change is done. It answers "cannot say" rather than guessing, and every answer carries what it did not check.',
109
+ supportedInterfaces: [{ url, transport: 'JSONRPC' }],
110
+ provider: { organization: 'Mamuti Aishan (Hasan)', url: 'https://github.com/hmamut39/tewip' },
111
+ version: versionOf(tewipRoot),
112
+ documentationUrl: 'https://github.com/hmamut39/tewip#readme',
113
+ // Both false, and the specification is explicit that an operation whose flag is false must
114
+ // return an error. Declaring a capability that is not implemented is the one thing a card must
115
+ // not do, and streaming a verdict that is computed in one step would buy nothing anyway.
116
+ capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
117
+ defaultInputModes: ['text/plain'],
118
+ defaultOutputModes: ['text/plain', 'text/markdown'],
119
+ skills: ANSWERABLE.map(({ verb: _verb, ...skill }) => skill),
120
+ iconUrl: 'https://raw.githubusercontent.com/hmamut39/tewip/main/assets/logo.svg',
121
+ };
122
+ }
123
+ /** The skill a caller asked for by id, if it is one Tewip answers. */
124
+ export function skillById(id) {
125
+ return ANSWERABLE.find((s) => s.id === id);
126
+ }
@@ -0,0 +1,171 @@
1
+ // Phase 34: serving Tewip to other agents over A2A.
2
+ //
3
+ // This is an HTTP port that runs commands against somebody's repository, so the guardrails are the
4
+ // design rather than a footnote:
5
+ //
6
+ // - **It listens on 127.0.0.1 and nowhere else** unless a person passes --host deliberately, and
7
+ // it says so loudly when they do. A quality tool that quietly opened a port on 0.0.0.0 would be
8
+ // a worse problem than any bug it found.
9
+ // - **Only the read-only skills are reachable.** The allowlist lives in card.ts; `fix`, `write`,
10
+ // `build`, `pursue` and `prove` are not on it, because they change files or run the suite.
11
+ // - **Every request is written to `.tewip/a2a.jsonl`**, the same way `pursue` records what it did,
12
+ // so there is a record of what another agent asked for and what it was told.
13
+ //
14
+ // The shapes below are v1.0's: method `SendMessage` in PascalCase, roles `ROLE_USER`/`ROLE_AGENT`,
15
+ // states like `TASK_STATE_COMPLETED`, and a Part carrying exactly one of text/raw/url/data.
16
+ import { createServer } from 'node:http';
17
+ import { appendFileSync, mkdirSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { agentCard } from './card.js';
21
+ import { askWhich, chooseSkill, runSkill } from './answer.js';
22
+ const JSONRPC_PATH = '/a2a/v1';
23
+ const CARD_PATH = '/.well-known/agent-card.json';
24
+ /** A2A's own error numbers sit outside the JSON-RPC reserved range; -32601 is "no such method". */
25
+ const METHOD_NOT_FOUND = -32601;
26
+ const INVALID_PARAMS = -32602;
27
+ const UNSUPPORTED = -32004;
28
+ function send(res, status, body) {
29
+ const text = JSON.stringify(body);
30
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
31
+ res.end(text);
32
+ }
33
+ const rpcError = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
34
+ /** A completed task carrying the verdict as one artifact, which is what A2A expects back. */
35
+ function completedTask(text, failed) {
36
+ return {
37
+ task: {
38
+ id: randomUUID(),
39
+ contextId: randomUUID(),
40
+ status: { state: failed ? 'TASK_STATE_FAILED' : 'TASK_STATE_COMPLETED', timestamp: new Date().toISOString() },
41
+ artifacts: [
42
+ {
43
+ artifactId: randomUUID(),
44
+ name: 'verdict',
45
+ description: "Tewip's answer, in the same words it would print for a person",
46
+ parts: [{ text, mediaType: 'text/markdown' }],
47
+ },
48
+ ],
49
+ },
50
+ };
51
+ }
52
+ /** A task that stops and asks, which is how Tewip declines to guess what was meant. */
53
+ function needsInput(text) {
54
+ return {
55
+ task: {
56
+ id: randomUUID(),
57
+ contextId: randomUUID(),
58
+ status: {
59
+ state: 'TASK_STATE_INPUT_REQUIRED',
60
+ timestamp: new Date().toISOString(),
61
+ message: { messageId: randomUUID(), role: 'ROLE_AGENT', parts: [{ text }] },
62
+ },
63
+ },
64
+ };
65
+ }
66
+ function record(root, entry) {
67
+ try {
68
+ const path = join(root, '.tewip', 'a2a.jsonl');
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, 'utf8');
71
+ }
72
+ catch {
73
+ // A log that cannot be written must not stop an answer being given.
74
+ }
75
+ }
76
+ function readBody(req, limit = 4 * 1024 * 1024) {
77
+ return new Promise((done, fail) => {
78
+ let body = '';
79
+ req.on('data', (chunk) => {
80
+ body += chunk.toString('utf8');
81
+ if (body.length > limit) {
82
+ fail(new Error('request too large'));
83
+ req.destroy();
84
+ }
85
+ });
86
+ req.on('end', () => done(body));
87
+ req.on('error', fail);
88
+ });
89
+ }
90
+ /** Everything the server does with one JSON-RPC call, kept apart from the socket for testing. */
91
+ export function handleRpc(cmd, body) {
92
+ if (typeof body !== 'object' || body === null)
93
+ return { status: 400, payload: rpcError(null, INVALID_PARAMS, 'the body is not a JSON-RPC object') };
94
+ const call = body;
95
+ const id = call.id ?? null;
96
+ const method = typeof call.method === 'string' ? call.method : '';
97
+ // The card says streaming and push notifications are false, and the specification requires an
98
+ // operation whose flag is false to fail rather than silently behave differently.
99
+ if (method === 'SendStreamingMessage' || method === 'SubscribeToTask') {
100
+ return { status: 200, payload: rpcError(id, UNSUPPORTED, 'this agent does not stream; its card declares capabilities.streaming false. Use SendMessage.') };
101
+ }
102
+ if (method.includes('PushNotificationConfig')) {
103
+ return { status: 200, payload: rpcError(id, UNSUPPORTED, 'this agent does not push; its card declares capabilities.pushNotifications false.') };
104
+ }
105
+ if (method !== 'SendMessage') {
106
+ return { status: 200, payload: rpcError(id, METHOD_NOT_FOUND, `this agent answers SendMessage only, and was asked for "${method}"`) };
107
+ }
108
+ const params = (call.params ?? {});
109
+ const message = (params.message ?? null);
110
+ if (message === null || typeof message !== 'object') {
111
+ return { status: 200, payload: rpcError(id, INVALID_PARAMS, 'params.message is required') };
112
+ }
113
+ const choice = chooseSkill(message);
114
+ if (choice.kind === 'ask') {
115
+ record(cmd.root, { method, outcome: 'asked-back', why: choice.why });
116
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: needsInput(askWhich(choice.why)) } };
117
+ }
118
+ if (choice.kind === 'unknown') {
119
+ record(cmd.root, { method, outcome: 'unknown-skill', asked: choice.asked });
120
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: needsInput(askWhich(`"${choice.asked}" is not a skill Tewip has`)) } };
121
+ }
122
+ const answered = runSkill(cmd.root, choice.skill, []);
123
+ record(cmd.root, { method, outcome: 'answered', skill: choice.skill.id, verb: choice.skill.verb, failed: answered.failed });
124
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: completedTask(answered.text, answered.failed) } };
125
+ }
126
+ /** True of an address that only this machine can reach. */
127
+ export const isLoopback = (host) => host === '127.0.0.1' || host === 'localhost' || host === '::1';
128
+ export function serveA2A(cmd) {
129
+ const root = resolve(cmd.root);
130
+ const url = `http://${cmd.host}:${cmd.port}${JSONRPC_PATH}`;
131
+ const server = createServer((req, res) => {
132
+ const path = (req.url ?? '').split('?')[0];
133
+ if (req.method === 'GET' && path === CARD_PATH) {
134
+ send(res, 200, agentCard(url, cmd.tewipRoot));
135
+ return;
136
+ }
137
+ if (req.method !== 'POST' || path !== JSONRPC_PATH) {
138
+ send(res, 404, { error: `nothing here. The card is at ${CARD_PATH} and calls go to ${JSONRPC_PATH}` });
139
+ return;
140
+ }
141
+ readBody(req)
142
+ .then((raw) => {
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ }
147
+ catch {
148
+ send(res, 400, rpcError(null, INVALID_PARAMS, 'the body is not JSON'));
149
+ return;
150
+ }
151
+ const { status, payload } = handleRpc({ ...cmd, root }, parsed);
152
+ send(res, status, payload);
153
+ })
154
+ .catch((error) => send(res, 413, rpcError(null, INVALID_PARAMS, error.message)));
155
+ });
156
+ server.listen(cmd.port, cmd.host, () => {
157
+ console.log(`Tewip is answering other agents about ${root}`);
158
+ console.log(` card: http://${cmd.host}:${cmd.port}${CARD_PATH}`);
159
+ console.log(` calls: POST ${url} (JSON-RPC, method "SendMessage")`);
160
+ if (!isLoopback(cmd.host)) {
161
+ console.log('');
162
+ console.log(` WARNING: ${cmd.host} is reachable from outside this machine, and this server has no`);
163
+ console.log(' authentication. Anything that can reach it can ask Tewip to read this repository.');
164
+ console.log(' Only the read-only skills are exposed - nothing here writes a file - but a port');
165
+ console.log(' that reads your source to strangers is still a port that reads your source.');
166
+ }
167
+ console.log('');
168
+ console.log('Every request is recorded in .tewip/a2a.jsonl. Stop with Ctrl+C.');
169
+ });
170
+ return server;
171
+ }
@@ -11,6 +11,12 @@ export function dialectOf(file, text) {
11
11
  return 'rails';
12
12
  if (/\bop\.(?:drop_column|add_column|create_index|alter_column|drop_table|rename_table|execute)\b/.test(text))
13
13
  return 'alembic';
14
+ // TypeORM writes a migration as a class with up() and down(), and puts the real DDL in template
15
+ // strings passed to queryRunner.query(). The SQL is right there and perfectly readable; only the
16
+ // wrapper was unfamiliar, so every TypeORM project was told Tewip could not read its migrations
17
+ // at all - and on a Node backend that is most of them.
18
+ if (/\bMigrationInterface\b|\bqueryRunner\.query\s*\(/.test(text))
19
+ return 'typeorm';
14
20
  if (/(^|\/)(migrations?|db\/migrate)\//.test(path))
15
21
  return 'unknown';
16
22
  return 'unknown';
@@ -157,7 +163,12 @@ const ALEMBIC = [
157
163
  table: (m) => m[1],
158
164
  },
159
165
  ];
160
- const BY_DIALECT = { django: DJANGO, rails: RAILS, alembic: ALEMBIC };
166
+ // TypeORM has no operations of its own: unlike Django's `RemoveField` or Rails's `remove_column`,
167
+ // a TypeORM migration contains SQL and nothing else, handed to queryRunner.query(). So its list is
168
+ // empty on purpose and every finding comes from reading that SQL, which is the same reader the
169
+ // `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
170
+ // TypeORM migration silently producing no findings at all.
171
+ const BY_DIALECT = { django: DJANGO, rails: RAILS, alembic: ALEMBIC, typeorm: [] };
161
172
  /** Findings from a framework migration, with the line each one sits on. */
162
173
  export function readFramework(dialect, text) {
163
174
  const lines = text.split(String.fromCharCode(10));
@@ -216,7 +227,10 @@ export function forwardOnly(dialect, text) {
216
227
  const line = lines[i];
217
228
  const down = (dialect === 'rails' && /^\s*def\s+down\b/.test(line)) ||
218
229
  (dialect === 'alembic' && /^\s*def\s+downgrade\s*\(/.test(line)) ||
219
- (dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line));
230
+ (dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line)) ||
231
+ // TypeORM's rollback is `public async down(queryRunner: QueryRunner)`. Reading it as part of
232
+ // the change would report a migration that adds a column as one that drops it.
233
+ (dialect === 'typeorm' && /^\s*(?:public\s+)?async\s+down\s*\(/.test(line));
220
234
  if (down) {
221
235
  // To the end of the block: the next line at the same indentation or less that starts something new.
222
236
  blankFrom(i, (l, indent) => l.trim() !== '' && indentOf(l) <= indent && !/^\s*(end|\})\s*$/.test(l), indentOf(line));
@@ -17,10 +17,39 @@ const MIGRATION = /(^|\/)(migrations?|db\/migrate|prisma\/migrations|alembic|fly
17
17
  const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|db\/migrate)\/.*\.(py|rb|js|ts)$/i;
18
18
  /** The schema files in this change. */
19
19
  export function schemaFiles(cmd) {
20
- const given = cmd.files && cmd.files.length > 0 ? cmd.files : (changeSummary(cmd.root, cmd.diff)?.files ?? []);
20
+ const named = cmd.files !== undefined && cmd.files.length > 0;
21
+ const given = named ? cmd.files : (changeSummary(cmd.root, cmd.diff)?.files ?? []);
21
22
  return given
22
23
  .map((f) => f.split('\\').join('/'))
23
- .filter((f) => (SCHEMA_FILE.test(f) || MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
24
+ // Asking for a file by name overrides the path filter: the filter exists to find migrations in
25
+ // a diff of a hundred files, not to argue with somebody who pointed at one. A TypeORM migration
26
+ // sitting outside a migrations/ directory was being answered with "no migration in this change".
27
+ .filter((f) => (named || SCHEMA_FILE.test(f) || MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
28
+ }
29
+ /**
30
+ * Tables this migration creates itself.
31
+ *
32
+ * A lock is only expensive because of the rows it holds up, and a table created three statements
33
+ * earlier has none. This matters enormously on the tools that generate their SQL: a Prisma project
34
+ * emits `CREATE TABLE` followed by an `ADD CONSTRAINT ... FOREIGN KEY` for every relation, so
35
+ * documenso's 165 real migrations produced 275 "blocks writes while it runs" findings - 1.7 per
36
+ * migration, nearly all of them about tables that did not exist a moment before. That is the volume
37
+ * at which a team stops reading the report, which costs more than the warnings are worth.
38
+ */
39
+ function createdHere(text) {
40
+ const names = new Set();
41
+ for (const m of text.matchAll(/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w$.]+)"?/gi)) {
42
+ names.add(m[1].toLowerCase().replace(/^.*\./, ''));
43
+ }
44
+ return names;
45
+ }
46
+ /** Risks that are only risks because rows already exist. */
47
+ const ONLY_HURTS_EXISTING_ROWS = new Set(['locks-writes', 'slow']);
48
+ export function droppingWhatIsNew(findings, text) {
49
+ const fresh = createdHere(text);
50
+ if (fresh.size === 0)
51
+ return findings;
52
+ return findings.filter((f) => !(ONLY_HURTS_EXISTING_ROWS.has(f.risk) && fresh.has(f.table.toLowerCase().replace(/^.*\./, ''))));
24
53
  }
25
54
  export function readMigration(root, file) {
26
55
  let text = '';
@@ -35,13 +64,13 @@ export function readMigration(root, file) {
35
64
  // part of the change says a migration that creates a table destroys one.
36
65
  const forward = forwardOnly(dialect, text);
37
66
  if (dialect === 'sql')
38
- return { file, dialect, findings: readSql(forward), unread: '', irreversible: false };
67
+ return { file, dialect, findings: droppingWhatIsNew(readSql(forward), forward), unread: '', irreversible: false };
39
68
  if (dialect === 'unknown') {
40
69
  return {
41
70
  file,
42
71
  dialect,
43
72
  findings: [],
44
- unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails and Alembic migrations',
73
+ unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails, Alembic and TypeORM migrations',
45
74
  irreversible: false,
46
75
  };
47
76
  }
@@ -49,10 +78,12 @@ export function readMigration(root, file) {
49
78
  // A migration that only runs raw SQL through the framework is still readable: the SQL is the migration.
50
79
  // Raw SQL, however it reaches the database: through the framework, or through a cursor inside a data
51
80
  // migration, which is where most large projects put the dangerous parts.
52
- const raw = [...forward.matchAll(/(?:RunSQL|op\.execute|execute|cursor\.execute)\(\s*(?:r?["'`]{1,3})([\s\S]*?)["'`]{1,3}\s*[,)]/g)].map((m) => m[1]).join(';');
81
+ // `queryRunner.query` is TypeORM's, and is the whole of a TypeORM migration rather than an
82
+ // escape hatch inside one: the class is a wrapper and every statement arrives this way.
83
+ const raw = [...forward.matchAll(/(?:RunSQL|op\.execute|queryRunner\.query|execute|cursor\.execute)\(\s*(?:r?["'`]{1,3})([\s\S]*?)["'`]{1,3}\s*[,)]/g)].map((m) => m[1]).join(';');
53
84
  if (raw.trim())
54
85
  findings.push(...readSql(raw));
55
- return { file, dialect, findings, unread: '', irreversible: irreversible(dialect, text) };
86
+ return { file, dialect, findings: droppingWhatIsNew(findings, forward), unread: '', irreversible: irreversible(dialect, text) };
56
87
  }
57
88
  /**
58
89
  * Code outside the migrations that still names a table or column this change takes away.
@@ -20,7 +20,12 @@ const RULES = [
20
20
  table: (m) => bare(m[1]),
21
21
  },
22
22
  {
23
- find: new RegExp(String.raw `\bALTER\s+TABLE\s+(${NAME})[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(${NAME})`, 'i'),
23
+ // The word COLUMN is optional in SQL, so the name after DROP has to be checked against the
24
+ // other things an ALTER TABLE can drop. Without the lookahead, Prisma's own
25
+ // `ALTER TABLE "Share" DROP CONSTRAINT "Share_userId_fkey"` - which every Prisma project is full
26
+ // of - was read as dropping a column called CONSTRAINT, and reported as breaking the running
27
+ // version, which dropping a foreign key does not do. Found on documenso's real migrations.
28
+ find: new RegExp(String.raw `\bALTER\s+TABLE\s+(${NAME})[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(?!CONSTRAINT\b|INDEX\b|DEFAULT\b|NOT\s+NULL\b|PRIMARY\b|FOREIGN\b|CHECK\b|UNIQUE\b|PARTITION\b)(${NAME})`, 'i'),
24
29
  risk: 'breaks-old-code',
25
30
  what: (m) => `drops the column ${bare(m[2])} from ${bare(m[1])}`,
26
31
  instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration. During a rolling deploy the old version is still running and will fail on a column that is gone',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "mcpName": "io.github.hmamut39/tewip",
5
5
  "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.",
6
6
  "keywords": [