instar 1.1.3 → 1.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -105,6 +105,7 @@
105
105
  "moltbridge": "^0.1.6",
106
106
  "picocolors": "^1.0.0",
107
107
  "proper-lockfile": "^4.1.2",
108
+ "telegram": "^2.26.22",
108
109
  "ws": "^8.19.0",
109
110
  "zod": "^4.3.6"
110
111
  },
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Repository-state invariants — CI safety net (Layer 4 of the
4
+ * test-env-isolation defense).
5
+ *
6
+ * Asserts properties of the checked-out tree that would otherwise be
7
+ * easy to clobber without anyone noticing on a code-review pass:
8
+ *
9
+ * 1. README.md is at least MIN_README_LINES (default: 100). The
10
+ * "# Test Project" stub-on-main incident (PRs #130/#277) cut it
11
+ * to 1 line and shipped to npm.
12
+ * 2. No fixture stowaway files at the repo root. file-0.txt and
13
+ * seed are signature artifacts of the test-fixture pollution
14
+ * class; they should never exist on main.
15
+ *
16
+ * Exit 0 = invariants hold. Exit 1 = at least one violated; the
17
+ * specific failures are printed to stderr.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ const ROOT = process.cwd();
24
+ const MIN_README_LINES = Number(process.env.INSTAR_README_MIN_LINES || 100);
25
+
26
+ const failures = [];
27
+
28
+ // ── README.md size floor ──────────────────────────────────────────
29
+ const readmePath = path.join(ROOT, 'README.md');
30
+ if (!fs.existsSync(readmePath)) {
31
+ failures.push(`README.md is missing from repo root.`);
32
+ } else {
33
+ const lines = fs.readFileSync(readmePath, 'utf-8').split('\n').length;
34
+ if (lines < MIN_README_LINES) {
35
+ failures.push(
36
+ `README.md has ${lines} lines, below the ${MIN_README_LINES}-line floor. ` +
37
+ `Likely test-fixture pollution clobbered the file. Restore from git history.`,
38
+ );
39
+ }
40
+ }
41
+
42
+ // ── Fixture stowaway files at repo root ───────────────────────────
43
+ const FIXTURE_STOWAWAYS = ['file-0.txt', 'seed'];
44
+ for (const name of FIXTURE_STOWAWAYS) {
45
+ const p = path.join(ROOT, name);
46
+ if (fs.existsSync(p)) {
47
+ const stat = fs.statSync(p);
48
+ if (stat.isFile()) {
49
+ failures.push(
50
+ `Fixture stowaway "${name}" exists at repo root. ` +
51
+ `This is the signature of a test committing into the real repo. ` +
52
+ `Delete the file and rebuild the test that produced it with sanitizedGitEnv().`,
53
+ );
54
+ }
55
+ }
56
+ }
57
+
58
+ if (failures.length === 0) {
59
+ console.log('✅ Repository invariants hold.');
60
+ process.exit(0);
61
+ }
62
+
63
+ process.stderr.write('\n🚫 Repository invariants violated:\n\n');
64
+ for (const f of failures) {
65
+ process.stderr.write(` - ${f}\n`);
66
+ }
67
+ process.stderr.write('\n');
68
+ process.exit(1);
@@ -0,0 +1,224 @@
1
+ /**
2
+ * TelegramHistorian — gramjs-based MTProto backfill for instar topic-memory.
3
+ *
4
+ * Built 2026-05-20 in response to topic 10873 incident: Echo's
5
+ * topic-memory.db was truncated during a Node 22→25 native-module cascade,
6
+ * losing the local mirror of multiple Telegram topics. Telegram's Bot API
7
+ * is forward-only — bots cannot fetch chat history. A user-account MTProto
8
+ * session can. This module is the gramjs half of the recovery path.
9
+ *
10
+ * Authority posture (read carefully before extending):
11
+ * - This module authenticates AS the operator's Telegram account. It is
12
+ * not a bot. It can read everything the operator's account can read,
13
+ * including DMs and private groups. That power requires discipline.
14
+ * - Permitted operations: iterMessages (read), getDialogs (read),
15
+ * getMessages (read). No send/edit/delete is ever issued from here.
16
+ * - The session string is the long-lived credential. It must live behind
17
+ * mode-0600 file permissions, outside any git-tracked path, and be
18
+ * migrated into a real secret store as soon as one is available.
19
+ * - Auth requires interactive SMS code entry by the operator. The
20
+ * onCode/onPassword callbacks here are the only interactive surface.
21
+ *
22
+ * Forum topics: in a Telegram forum supergroup, each topic is identified
23
+ * by the message id of its topic-header message. Replies and posts in a
24
+ * topic carry reply_to.forum_topic = true and reply_to.reply_to_top_id =
25
+ * <topic_id>. gramjs surfaces this via the `replyTo` option to
26
+ * iterMessages — a value matching the topic id filters to that topic.
27
+ */
28
+
29
+ import { TelegramClient } from 'telegram';
30
+ import { StringSession } from 'telegram/sessions/index.js';
31
+ import { Api } from 'telegram';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+
35
+ /**
36
+ * @typedef {Object} HistorianCredentials
37
+ * @property {number} apiId
38
+ * @property {string} apiHash
39
+ * @property {string} sessionFilePath Absolute path to the session string file
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} AuthPrompts
44
+ * @property {() => Promise<string>} onPhoneNumber Required if no saved session
45
+ * @property {() => Promise<string>} onCode Required if no saved session
46
+ * @property {() => Promise<string>} [onPassword] Optional 2FA password
47
+ * @property {(error: Error) => void} [onError]
48
+ */
49
+
50
+ const CONNECTION_RETRIES = 5;
51
+
52
+ export class TelegramHistorian {
53
+ /**
54
+ * @param {HistorianCredentials} creds
55
+ * @param {{ verbose?: boolean }} [opts]
56
+ */
57
+ constructor(creds, opts = {}) {
58
+ this.creds = creds;
59
+ this.verbose = opts.verbose ?? false;
60
+ this.client = null;
61
+ }
62
+
63
+ _loadSavedSession() {
64
+ if (!this.creds.sessionFilePath) return '';
65
+ if (!fs.existsSync(this.creds.sessionFilePath)) return '';
66
+ return fs.readFileSync(this.creds.sessionFilePath, 'utf8').trim();
67
+ }
68
+
69
+ _saveSession(sessionStr) {
70
+ if (!this.creds.sessionFilePath) return;
71
+ fs.mkdirSync(path.dirname(this.creds.sessionFilePath), { recursive: true, mode: 0o700 });
72
+ fs.writeFileSync(this.creds.sessionFilePath, sessionStr, { mode: 0o600 });
73
+ }
74
+
75
+ /**
76
+ * Connect and authenticate. If a saved session string exists at
77
+ * creds.sessionFilePath, reuses it (no SMS needed). Otherwise drives
78
+ * the interactive auth flow through the provided callbacks.
79
+ *
80
+ * @param {AuthPrompts} prompts
81
+ */
82
+ async connect(prompts) {
83
+ const saved = this._loadSavedSession();
84
+ const session = new StringSession(saved);
85
+ this.client = new TelegramClient(session, this.creds.apiId, this.creds.apiHash, {
86
+ connectionRetries: CONNECTION_RETRIES,
87
+ });
88
+
89
+ await this.client.start({
90
+ phoneNumber: prompts.onPhoneNumber,
91
+ phoneCode: prompts.onCode,
92
+ password: prompts.onPassword ?? (async () => {
93
+ throw new Error('2FA password required but no onPassword callback provided');
94
+ }),
95
+ onError: prompts.onError ?? ((err) => {
96
+ // eslint-disable-next-line no-console
97
+ console.error('[TelegramHistorian] auth error:', err.message);
98
+ }),
99
+ });
100
+
101
+ const sessionStr = this.client.session.save();
102
+ if (sessionStr && sessionStr !== saved) {
103
+ this._saveSession(sessionStr);
104
+ if (this.verbose) {
105
+ // eslint-disable-next-line no-console
106
+ console.error('[TelegramHistorian] session saved to', this.creds.sessionFilePath);
107
+ }
108
+ }
109
+ }
110
+
111
+ async disconnect() {
112
+ if (this.client) {
113
+ await this.client.disconnect();
114
+ this.client = null;
115
+ }
116
+ }
117
+
118
+ _ensureConnected() {
119
+ if (!this.client) throw new Error('TelegramHistorian: not connected — call connect() first');
120
+ }
121
+
122
+ /**
123
+ * Resolve a supergroup reference (numeric id like -1003742343280, or
124
+ * @username) to a gramjs entity.
125
+ * @param {string | number | bigint} chatRef
126
+ */
127
+ async resolveChat(chatRef) {
128
+ this._ensureConnected();
129
+ return await this.client.getEntity(chatRef);
130
+ }
131
+
132
+ /**
133
+ * List forum topics in a supergroup. Returns each topic's id, title, and
134
+ * top_message id.
135
+ * @param {string | number | bigint} chatRef
136
+ * @returns {Promise<Array<{ id: number, title: string, topMessageId: number, closed: boolean }>>}
137
+ */
138
+ async listForumTopics(chatRef) {
139
+ this._ensureConnected();
140
+ const entity = await this.resolveChat(chatRef);
141
+ const result = await this.client.invoke(
142
+ new Api.channels.GetForumTopics({
143
+ channel: entity,
144
+ offsetDate: 0,
145
+ offsetId: 0,
146
+ offsetTopic: 0,
147
+ limit: 100,
148
+ }),
149
+ );
150
+ const topics = [];
151
+ for (const t of result.topics) {
152
+ // ForumTopic vs ForumTopicDeleted
153
+ if (t.className === 'ForumTopic') {
154
+ topics.push({
155
+ id: Number(t.id),
156
+ title: t.title,
157
+ topMessageId: Number(t.topMessage ?? t.id),
158
+ closed: Boolean(t.closed),
159
+ });
160
+ }
161
+ }
162
+ return topics;
163
+ }
164
+
165
+ /**
166
+ * Iterate all messages in a single forum topic, oldest-first. Yields
167
+ * one BackfillMessage at a time. Handles FloodWait via gramjs's
168
+ * built-in retry. Caller is responsible for importing.
169
+ *
170
+ * @param {string | number | bigint} chatRef
171
+ * @param {number} topicId forum topic id
172
+ * @param {{ minMessageId?: number, batchSize?: number }} [opts]
173
+ */
174
+ async *iterTopicMessages(chatRef, topicId, opts = {}) {
175
+ this._ensureConnected();
176
+ const entity = await this.resolveChat(chatRef);
177
+ const batchSize = opts.batchSize ?? 100;
178
+ const minMessageId = opts.minMessageId ?? 0;
179
+
180
+ const iter = this.client.iterMessages(entity, {
181
+ replyTo: topicId,
182
+ limit: undefined,
183
+ reverse: true,
184
+ minId: minMessageId,
185
+ filter: undefined,
186
+ });
187
+
188
+ let pageCount = 0;
189
+ let yielded = 0;
190
+ for await (const msg of iter) {
191
+ if (!msg) continue;
192
+ const text = typeof msg.message === 'string' ? msg.message : '';
193
+ const sender = msg.sender ?? null;
194
+ const fromUser = msg.out === true ? 1 : 0;
195
+ const ts = new Date(Number(msg.date) * 1000).toISOString();
196
+
197
+ yield {
198
+ messageId: Number(msg.id),
199
+ topicId,
200
+ text,
201
+ timestamp: ts,
202
+ fromUser,
203
+ senderName: sender?.firstName
204
+ ? [sender.firstName, sender.lastName].filter(Boolean).join(' ')
205
+ : null,
206
+ senderUsername: sender?.username ?? null,
207
+ telegramUserId: sender?.id != null ? Number(sender.id) : null,
208
+ userId: null,
209
+ sessionName: null,
210
+ privacyScope: 'private',
211
+ };
212
+ yielded++;
213
+ if (yielded % batchSize === 0) {
214
+ pageCount++;
215
+ if (this.verbose) {
216
+ // eslint-disable-next-line no-console
217
+ console.error(
218
+ `[TelegramHistorian] topic ${topicId}: page ${pageCount} (${yielded} so far)`,
219
+ );
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * topic-memory-importer — idempotent import of Telegram messages into
3
+ * .instar/topic-memory.db.
4
+ *
5
+ * Built 2026-05-20 after the local topic-memory.db was truncated to 4
6
+ * messages during the Node 22→25 better-sqlite3 cascade recovery (incident
7
+ * recorded in topic 10873). Bots cannot fetch Telegram history; only a
8
+ * user-account MTProto session can. This module is the storage half of
9
+ * that backfill path — gramjs fetches, this writes.
10
+ *
11
+ * The schema (read 2026-05-20 from a live agent home):
12
+ *
13
+ * messages(id PK auto, message_id, topic_id, text, from_user, timestamp,
14
+ * session_name, sender_name, sender_username, telegram_user_id,
15
+ * user_id, privacy_scope, UNIQUE(message_id, topic_id))
16
+ *
17
+ * The UNIQUE(message_id, topic_id) constraint is the idempotency primitive.
18
+ * `INSERT OR IGNORE` is the only sanctioned write path here — re-running
19
+ * the importer against the same input is a no-op.
20
+ *
21
+ * Concurrency: the instar server (echo's own process) holds topic-memory.db
22
+ * open under WAL mode. SQLite serializes writes across processes via WAL,
23
+ * so this importer can write while the server is reading without
24
+ * corruption. The FTS triggers maintain the FTS index automatically.
25
+ */
26
+
27
+ import Database from 'better-sqlite3';
28
+
29
+ /**
30
+ * @typedef {Object} BackfillMessage
31
+ * @property {number} messageId Telegram message id (per chat)
32
+ * @property {number} topicId Forum topic id (= message id of the
33
+ * topic-header message in the supergroup)
34
+ * @property {string} text Message text. Empty string for
35
+ * service messages / media-only.
36
+ * @property {string} timestamp ISO 8601 UTC
37
+ * @property {number} [fromUser] 1 if message was sent by the user
38
+ * (justin), 0 if from any agent
39
+ * @property {string} [senderName] Display name
40
+ * @property {string} [senderUsername] @handle, no leading @
41
+ * @property {number} [telegramUserId] Numeric Telegram user id
42
+ * @property {string} [userId] instar user id, if known
43
+ * @property {string} [sessionName] instar session name, if known
44
+ * @property {string} [privacyScope] Defaults to 'private'
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} ImportResult
49
+ * @property {number} inserted
50
+ * @property {number} skipped
51
+ * @property {Set<number>} touchedTopics
52
+ */
53
+
54
+ export class TopicMemoryImporter {
55
+ /**
56
+ * @param {string} dbPath absolute path to topic-memory.db
57
+ * @param {{ readonly?: boolean, journalMode?: 'wal' | 'delete' }} [opts]
58
+ */
59
+ constructor(dbPath, opts = {}) {
60
+ this.db = new Database(dbPath, { readonly: opts.readonly ?? false });
61
+ this.db.pragma(`journal_mode = ${opts.journalMode ?? 'wal'}`);
62
+ this.db.pragma('busy_timeout = 5000');
63
+
64
+ this._insert = this.db.prepare(`
65
+ INSERT OR IGNORE INTO messages (
66
+ message_id, topic_id, text, from_user, timestamp,
67
+ session_name, sender_name, sender_username, telegram_user_id,
68
+ user_id, privacy_scope
69
+ ) VALUES (
70
+ @messageId, @topicId, @text, @fromUser, @timestamp,
71
+ @sessionName, @senderName, @senderUsername, @telegramUserId,
72
+ @userId, @privacyScope
73
+ )
74
+ `);
75
+
76
+ this._countByTopic = this.db.prepare(
77
+ `SELECT topic_id, COUNT(*) AS n FROM messages GROUP BY topic_id`,
78
+ );
79
+ }
80
+
81
+ close() {
82
+ this.db.close();
83
+ }
84
+
85
+ /**
86
+ * Import a batch of messages. All-or-nothing per call (wrapped in a
87
+ * transaction). Safe to retry — the UNIQUE(message_id, topic_id)
88
+ * constraint plus INSERT OR IGNORE means duplicates are silently dropped.
89
+ *
90
+ * @param {BackfillMessage[]} messages
91
+ * @returns {ImportResult}
92
+ */
93
+ importBatch(messages) {
94
+ const touched = new Set();
95
+ let inserted = 0;
96
+ let skipped = 0;
97
+ const tx = this.db.transaction((batch) => {
98
+ for (const m of batch) {
99
+ const row = this._insert.run({
100
+ messageId: m.messageId,
101
+ topicId: m.topicId,
102
+ text: m.text ?? '',
103
+ fromUser: m.fromUser ?? 0,
104
+ timestamp: m.timestamp,
105
+ sessionName: m.sessionName ?? null,
106
+ senderName: m.senderName ?? null,
107
+ senderUsername: m.senderUsername ?? null,
108
+ telegramUserId: m.telegramUserId ?? null,
109
+ userId: m.userId ?? null,
110
+ privacyScope: m.privacyScope ?? 'private',
111
+ });
112
+ if (row.changes === 1) inserted++;
113
+ else skipped++;
114
+ touched.add(m.topicId);
115
+ }
116
+ });
117
+ tx(messages);
118
+ return { inserted, skipped, touchedTopics: touched };
119
+ }
120
+
121
+ /**
122
+ * Current message counts keyed by topic_id.
123
+ * @returns {Map<number, number>}
124
+ */
125
+ countsByTopic() {
126
+ const m = new Map();
127
+ for (const row of this._countByTopic.all()) {
128
+ m.set(row.topic_id, row.n);
129
+ }
130
+ return m;
131
+ }
132
+ }
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // safe-git-allow: pre-push critical path — must stay lightweight, no SafeGitExecutor audit-log writes from the hook.
3
+ /**
4
+ * Pre-push guard: reject test-fixture commits before they ship.
5
+ *
6
+ * Git invokes pre-push hooks with GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE
7
+ * set in the env. When the hook runs `npm run test:smoke`, the test
8
+ * suite inherits those overrides, and any test that spawns `git init`
9
+ * + `git commit` in a tmpdir without sanitizing env ends up committing
10
+ * into the real repo on whichever branch is currently checked out.
11
+ *
12
+ * That's how PRs #130 and #277 picked up "Initial commit" / "seed" /
13
+ * "Worktree commit N" stowaways that overwrote README.md on main.
14
+ *
15
+ * Layer 1 (vitest-setup.ts strip) closes the root cause. This guard is
16
+ * the second line of defense: even if a future test reintroduces a raw
17
+ * git call without sanitizing env, the resulting commit can never reach
18
+ * the remote because this hook refuses to push it.
19
+ *
20
+ * Behavior:
21
+ * - Scans commits on HEAD that aren't yet on origin/main.
22
+ * - Fails the push if any of them match the fixture-pollution signature
23
+ * (author email test@instar.local / t@t.com / t@e.com, or commit
24
+ * messages "Initial commit" / "seed" / "Worktree commit <N>").
25
+ * - Prints what it found so the author can `git reset` past the bad
26
+ * commits and re-push.
27
+ *
28
+ * Escape hatch (for legitimate fixture commits in test-only branches
29
+ * that will never merge): INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1
30
+ *
31
+ * Exit codes: 0 = clean (or skipped), 1 = pollution detected.
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+
36
+ const SKIP = process.env.INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP === '1';
37
+ if (SKIP) {
38
+ console.log('⏭️ INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1 — fixture guard bypassed.');
39
+ process.exit(0);
40
+ }
41
+
42
+ // Strip the same env overrides we accuse of causing the bug. Otherwise
43
+ // our own `git log` here would be subject to the same redirection.
44
+ const env = { ...process.env };
45
+ delete env.GIT_DIR;
46
+ delete env.GIT_WORK_TREE;
47
+ delete env.GIT_INDEX_FILE;
48
+ delete env.GIT_OBJECT_DIRECTORY;
49
+ delete env.GIT_COMMON_DIR;
50
+
51
+ function git(args) {
52
+ return execFileSync('git', args, { encoding: 'utf-8', env, stdio: ['ignore', 'pipe', 'pipe'] });
53
+ }
54
+
55
+ let baseRef;
56
+ try {
57
+ // Prefer upstream tracking branch if one is configured.
58
+ baseRef = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).trim();
59
+ } catch {
60
+ // No upstream — fall back to whichever remote main we can resolve.
61
+ for (const candidate of ['upstream/main', 'origin/main']) {
62
+ try {
63
+ git(['rev-parse', '--verify', '--quiet', candidate]);
64
+ baseRef = candidate;
65
+ break;
66
+ } catch { /* try next */ }
67
+ }
68
+ }
69
+
70
+ if (!baseRef) {
71
+ // No remote main to compare against — likely a fresh repo. Skip silently.
72
+ process.exit(0);
73
+ }
74
+
75
+ // `<base>..HEAD` lists commits on HEAD that aren't on base. NUL-separated
76
+ // records of "<sha>\t<author_email>\t<author_name>\t<subject>" — using \0
77
+ // between records lets us survive commit messages that contain newlines.
78
+ let log;
79
+ try {
80
+ log = git([
81
+ 'log',
82
+ `${baseRef}..HEAD`,
83
+ '--pretty=format:%H%x09%ae%x09%an%x09%s%x00',
84
+ ]);
85
+ } catch (err) {
86
+ // Couldn't compute the range. Don't block the push — a real failure
87
+ // would surface elsewhere; we'd rather miss a guard than wedge pushes
88
+ // on every fresh repo / unborn branch.
89
+ process.exit(0);
90
+ }
91
+
92
+ const records = log.split('\0').map((r) => r.trim()).filter(Boolean);
93
+
94
+ const BAD_EMAILS = new Set([
95
+ 'test@instar.local',
96
+ 't@t.com',
97
+ 't@e.com',
98
+ 'test@test.com',
99
+ ]);
100
+
101
+ const BAD_SUBJECT_PATTERNS = [
102
+ /^Initial commit$/,
103
+ /^seed$/,
104
+ /^Worktree commit \d+$/,
105
+ /^init$/,
106
+ ];
107
+
108
+ const violations = [];
109
+ for (const record of records) {
110
+ const [sha, email, name, subject] = record.split('\t');
111
+ const reasons = [];
112
+ if (BAD_EMAILS.has(email)) {
113
+ reasons.push(`fixture-identity author <${email}>`);
114
+ }
115
+ for (const pattern of BAD_SUBJECT_PATTERNS) {
116
+ if (pattern.test(subject)) {
117
+ reasons.push(`fixture commit message "${subject}"`);
118
+ break;
119
+ }
120
+ }
121
+ if (reasons.length > 0) {
122
+ violations.push({ sha: sha.slice(0, 10), name, email, subject, reasons });
123
+ }
124
+ }
125
+
126
+ if (violations.length === 0) {
127
+ process.exit(0);
128
+ }
129
+
130
+ const lines = [];
131
+ lines.push('');
132
+ lines.push('🚫 pre-push-fixture-guard: refusing to push test-fixture commits.');
133
+ lines.push('');
134
+ lines.push('These commits look like test-fixture pollution (test wrote into');
135
+ lines.push('the real repo because GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE');
136
+ lines.push('leaked from this pre-push hook into a child test process):');
137
+ lines.push('');
138
+ for (const v of violations) {
139
+ lines.push(` ${v.sha} ${v.name} <${v.email}> "${v.subject}"`);
140
+ for (const r of v.reasons) {
141
+ lines.push(` → ${r}`);
142
+ }
143
+ }
144
+ lines.push('');
145
+ lines.push('How to recover:');
146
+ lines.push(` 1. Inspect the commits: git log ${baseRef}..HEAD`);
147
+ lines.push(` 2. Drop them: git reset --hard <last good sha>`);
148
+ lines.push(' 3. Force-push the cleaned branch.');
149
+ lines.push('');
150
+ lines.push('If this is intentional (e.g. a test-only branch you do not plan');
151
+ lines.push('to merge), bypass with INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1.');
152
+ lines.push('');
153
+
154
+ process.stderr.write(lines.join('\n'));
155
+ process.exit(1);