@flowrail/init 0.0.9 → 0.0.11

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.
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ /**
3
+ * ADR-019 M0' — durable app-instance identity (Decision 1).
4
+ *
5
+ * ``init`` mints an ``app_instance_id`` (mint-if-absent) into
6
+ * ``.flowrail/app.json`` and registers it with the server via the
7
+ * ``flowrail_register_app`` MCP tool. The identity is DURABLE:
8
+ *
9
+ * - it lives in its own file, NOT ``context.json`` — the design-review
10
+ * handoff file is rewritten by the skill on every review and carries
11
+ * a 7-day staleness rule, both fatal for an identity that must
12
+ * survive the life of the clone;
13
+ * - an existing file is never re-minted or clobbered with a different
14
+ * id (``writeAppIdentity`` throws rather than overwrite);
15
+ * - registration is fail-soft — a dead network still yields a local
16
+ * identity (``local_only``) and the next ``init`` re-registers it
17
+ * idempotently.
18
+ *
19
+ * ``repo_key`` follows the hook's repo-scan convention EXACTLY
20
+ * (sha256 hex of the RAW remote string, first 16 chars; path fallback)
21
+ * so the lineage hint agrees across scan and registration events. The
22
+ * ``normalized_remote`` (canonical https form) exists only for the
23
+ * server-side rebind lookup — it is never hashed into ``repo_key``.
24
+ */
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.mintAppInstanceId = mintAppInstanceId;
30
+ exports.normalizeRemoteUrl = normalizeRemoteUrl;
31
+ exports.readAppIdentity = readAppIdentity;
32
+ exports.writeAppIdentity = writeAppIdentity;
33
+ exports.resolveRepoIdentity = resolveRepoIdentity;
34
+ exports.ensureAppInstance = ensureAppInstance;
35
+ const child_process_1 = require("child_process");
36
+ const crypto_1 = __importDefault(require("crypto"));
37
+ const fs_1 = __importDefault(require("fs"));
38
+ const path_1 = __importDefault(require("path"));
39
+ const APP_JSON_DIR = '.flowrail';
40
+ const APP_JSON_FILE = 'app.json';
41
+ const REGISTER_TIMEOUT_MS = 10000;
42
+ function mintAppInstanceId() {
43
+ return 'app_' + crypto_1.default.randomBytes(16).toString('hex');
44
+ }
45
+ /**
46
+ * Canonical https form of a git remote, used ONLY for the server-side
47
+ * rebind lookup. Handles scp-style ssh (``git@host:path``), ``ssh://``,
48
+ * and https (with embedded credentials stripped). Host is lowercased;
49
+ * path case is PRESERVED (GitHub owner/repo lookups are case-preserving
50
+ * even though resolution is case-insensitive).
51
+ */
52
+ function normalizeRemoteUrl(raw) {
53
+ const trimmed = raw.trim();
54
+ let host;
55
+ let pathPart;
56
+ const scp = /^[A-Za-z0-9._-]+@([^:/]+):(.+)$/.exec(trimmed);
57
+ if (scp && !trimmed.includes('://')) {
58
+ host = scp[1];
59
+ pathPart = scp[2];
60
+ }
61
+ else {
62
+ const proto = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(trimmed);
63
+ let rest = proto ? proto[1] : trimmed;
64
+ // Strip credentials: anything before an ``@`` that precedes the
65
+ // first path slash belongs to userinfo, never the host.
66
+ const firstSlash = rest.indexOf('/');
67
+ const at = rest.indexOf('@');
68
+ if (at !== -1 && (firstSlash === -1 || at < firstSlash)) {
69
+ rest = rest.slice(at + 1);
70
+ }
71
+ const slash = rest.indexOf('/');
72
+ if (slash === -1) {
73
+ host = rest;
74
+ pathPart = '';
75
+ }
76
+ else {
77
+ host = rest.slice(0, slash);
78
+ pathPart = rest.slice(slash + 1);
79
+ }
80
+ }
81
+ pathPart = pathPart.replace(/\/+$/, '').replace(/\.git$/, '');
82
+ return `https://${host.toLowerCase()}${pathPart ? `/${pathPart}` : ''}`;
83
+ }
84
+ /** Read ``.flowrail/app.json``. Durable by design: NO staleness rule —
85
+ * contrast the hook's context.json reader, which drops 7-day-old
86
+ * contexts. Returns null on missing/malformed/typeless content. */
87
+ function readAppIdentity(projectRoot) {
88
+ let raw;
89
+ try {
90
+ raw = fs_1.default.readFileSync(path_1.default.join(projectRoot, APP_JSON_DIR, APP_JSON_FILE), 'utf8');
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(raw);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
103
+ return null;
104
+ }
105
+ const obj = parsed;
106
+ if (typeof obj['app_instance_id'] !== 'string')
107
+ return null;
108
+ return {
109
+ app_instance_id: obj['app_instance_id'],
110
+ created_at: typeof obj['created_at'] === 'string' ? obj['created_at'] : '',
111
+ };
112
+ }
113
+ /** Write ``.flowrail/app.json``. Never clobbers a DIFFERENT id — the
114
+ * mint is permanent for the life of the clone (re-mint only via
115
+ * delete/re-clone, per ADR-019 Decision 1). */
116
+ function writeAppIdentity(projectRoot, identity) {
117
+ const existing = readAppIdentity(projectRoot);
118
+ if (existing !== null) {
119
+ if (existing.app_instance_id !== identity.app_instance_id) {
120
+ throw new Error(`.flowrail/app.json already holds app_instance_id ${existing.app_instance_id}; ` +
121
+ 'refusing to overwrite a durable identity with a different one');
122
+ }
123
+ return; // idempotent: same id, keep the original file
124
+ }
125
+ const dir = path_1.default.join(projectRoot, APP_JSON_DIR);
126
+ fs_1.default.mkdirSync(dir, { recursive: true });
127
+ fs_1.default.writeFileSync(path_1.default.join(dir, APP_JSON_FILE), JSON.stringify(identity, null, 2) + '\n', 'utf8');
128
+ }
129
+ function defaultGitRemote(projectRoot) {
130
+ try {
131
+ const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 }).trim();
132
+ return out.length > 0 ? out : undefined;
133
+ }
134
+ catch {
135
+ return undefined;
136
+ }
137
+ }
138
+ function shortHash(value) {
139
+ return crypto_1.default.createHash('sha256').update(value).digest('hex').slice(0, 16);
140
+ }
141
+ /**
142
+ * Resolve the repo identity triple. ``repoKey`` hashes the RAW remote
143
+ * string (never the normalized form) to stay byte-identical with the
144
+ * hook's repo-scan ``resolveRepoKey`` — the two surfaces must agree or
145
+ * lineage grouping by repo_key silently forks.
146
+ */
147
+ function resolveRepoIdentity(projectRoot, deps = {}) {
148
+ const gitRemote = deps.gitRemote ?? defaultGitRemote;
149
+ const raw = gitRemote(projectRoot);
150
+ if (raw !== undefined) {
151
+ return {
152
+ normalizedRemote: normalizeRemoteUrl(raw),
153
+ repoKey: shortHash(raw),
154
+ remotePresent: true,
155
+ };
156
+ }
157
+ return {
158
+ normalizedRemote: null,
159
+ repoKey: shortHash(path_1.default.resolve(projectRoot)),
160
+ remotePresent: false,
161
+ };
162
+ }
163
+ function buildDefaultRegisterApp(mcpBaseUrl, apiKey) {
164
+ const url = `${mcpBaseUrl.replace(/\/+$/, '')}/mcp`;
165
+ return async (args) => {
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), REGISTER_TIMEOUT_MS);
168
+ try {
169
+ const response = await fetch(url, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'content-type': 'application/json',
173
+ authorization: `Bearer ${apiKey}`,
174
+ },
175
+ body: JSON.stringify({
176
+ jsonrpc: '2.0',
177
+ id: 1,
178
+ method: 'tools/call',
179
+ params: { name: 'flowrail_register_app', arguments: args },
180
+ }),
181
+ signal: controller.signal,
182
+ });
183
+ if (!response.ok) {
184
+ return { error: `HTTP ${response.status}` };
185
+ }
186
+ const body = (await response.json());
187
+ if (body.error) {
188
+ return { error: body.error.message ?? 'JSON-RPC error' };
189
+ }
190
+ const structured = body.result?.structuredContent;
191
+ if (structured &&
192
+ typeof structured['status'] === 'string' &&
193
+ typeof structured['app_instance_id'] === 'string') {
194
+ return structured;
195
+ }
196
+ return { error: 'malformed flowrail_register_app response' };
197
+ }
198
+ catch (err) {
199
+ return { error: err instanceof Error ? err.message : String(err) };
200
+ }
201
+ finally {
202
+ clearTimeout(timer);
203
+ }
204
+ };
205
+ }
206
+ /**
207
+ * Mint-if-absent + register. Every path yields a durable local identity;
208
+ * only the registration round-trip is best-effort.
209
+ */
210
+ async function ensureAppInstance(opts) {
211
+ const registerApp = opts.registerApp ?? buildDefaultRegisterApp(opts.mcpBaseUrl, opts.apiKey);
212
+ const repo = resolveRepoIdentity(opts.projectRoot);
213
+ const registerArgs = (appInstanceId, extra = {}) => {
214
+ const args = {
215
+ app_instance_id: appInstanceId,
216
+ repo_key: repo.repoKey,
217
+ remote_present: repo.remotePresent,
218
+ ...extra,
219
+ };
220
+ if (repo.normalizedRemote !== null) {
221
+ args['normalized_remote'] = repo.normalizedRemote;
222
+ }
223
+ return args;
224
+ };
225
+ const callRegister = async (args) => {
226
+ try {
227
+ return await registerApp(args);
228
+ }
229
+ catch (err) {
230
+ return { error: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ };
233
+ const existing = readAppIdentity(opts.projectRoot);
234
+ if (existing !== null) {
235
+ // Idempotent re-register of the durable id. Any server-side outcome
236
+ // that acknowledges the id maps to 'ok'; failure is fail-soft.
237
+ const res = await callRegister(registerArgs(existing.app_instance_id));
238
+ if ('error' in res) {
239
+ opts.log(`FlowRail: app-instance registration deferred (${res.error}); ` +
240
+ 'the local identity is kept and re-registered on the next init.');
241
+ return { appInstanceId: existing.app_instance_id, status: 'local_only' };
242
+ }
243
+ return { appInstanceId: existing.app_instance_id, status: 'ok' };
244
+ }
245
+ const minted = mintAppInstanceId();
246
+ const identity = {
247
+ app_instance_id: minted,
248
+ created_at: new Date().toISOString(),
249
+ };
250
+ const first = await callRegister(registerArgs(minted));
251
+ if ('error' in first) {
252
+ writeAppIdentity(opts.projectRoot, identity);
253
+ opts.log(`FlowRail: app-instance registration deferred (${first.error}); ` +
254
+ 'minted a local identity — the next init re-registers it.');
255
+ return { appInstanceId: minted, status: 'local_only' };
256
+ }
257
+ if (first.status === 'existing') {
258
+ // The rebind offer: this remote is already registered under a
259
+ // different instance id (a prior clone).
260
+ if (opts.rebind) {
261
+ const adopted = {
262
+ app_instance_id: first.app_instance_id,
263
+ created_at: first.created_at ?? new Date().toISOString(),
264
+ };
265
+ writeAppIdentity(opts.projectRoot, adopted);
266
+ opts.log(`✓ rebound to existing app instance ${first.app_instance_id} for this remote`);
267
+ return {
268
+ appInstanceId: first.app_instance_id,
269
+ status: 'existing_adopted',
270
+ };
271
+ }
272
+ opts.log(`FlowRail: this remote is already registered as app instance ${first.app_instance_id}.`);
273
+ opts.log(' Minting a fresh instance for this clone (graphs are per-clone). To adopt the');
274
+ opts.log(' existing identity instead, re-run init with --rebind (or FLOWRAIL_APP_REBIND=1).');
275
+ const forced = await callRegister(registerArgs(minted, { force_new: true }));
276
+ writeAppIdentity(opts.projectRoot, identity);
277
+ if ('error' in forced) {
278
+ opts.log(`FlowRail: forced registration deferred (${forced.error}); ` +
279
+ 'the local identity is kept and re-registered on the next init.');
280
+ return { appInstanceId: minted, status: 'local_only' };
281
+ }
282
+ return { appInstanceId: minted, status: 'forced_new' };
283
+ }
284
+ // 'registered' (or an unexpectedly-lenient 'ok') — persist the mint.
285
+ writeAppIdentity(opts.projectRoot, identity);
286
+ return { appInstanceId: minted, status: 'registered' };
287
+ }
@@ -3,15 +3,16 @@
3
3
  * Read / merge / write ``.claude/settings.json``.
4
4
  *
5
5
  * Wires PreToolUse hooks for Write/Edit/MultiEdit (pre-write
6
- * dispatcher) and Bash (pre-bash dispatcher). Both invoke
7
- * ``flowrail-hook`` from the @flowrail/hook package the tester is
8
- * expected to have installed alongside this init.
6
+ * dispatcher) and Bash (pre-bash dispatcher), plus the ADR-019 Stop
7
+ * (turn-batched whole-app review) and SessionStart (coverage catch-up)
8
+ * hooks. All invoke ``flowrail-hook`` from the @flowrail/hook package
9
+ * the tester is expected to have installed alongside this init.
9
10
  */
10
11
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
12
13
  };
13
14
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.PRE_BASH_MATCHER = exports.PRE_WRITE_MATCHER = void 0;
15
+ exports.REVIEW_HOOK_TIMEOUT_SECONDS = exports.PRE_BASH_MATCHER = exports.PRE_WRITE_MATCHER = void 0;
15
16
  exports.buildHookCommand = buildHookCommand;
16
17
  exports.mergeClaudeSettings = mergeClaudeSettings;
17
18
  exports.writeClaudeSettings = writeClaudeSettings;
@@ -26,6 +27,12 @@ const HOOK_BIN_NAME = 'flowrail-hook';
26
27
  const HOOK_BIN = `npx --yes -p @flowrail/hook ${HOOK_BIN_NAME}`;
27
28
  exports.PRE_WRITE_MATCHER = 'Write|Edit|MultiEdit';
28
29
  exports.PRE_BASH_MATCHER = 'Bash';
30
+ // ADR-019 failure posture: the Stop/SessionStart hook timeout is sized
31
+ // to batched whole-app reality and set EXPLICITLY in the generated
32
+ // settings entry (seconds). It sits above the hook's own HTTP client
33
+ // timeout so the client, not Claude Code, decides how a slow review
34
+ // degrades (fail-open with telemetry, never a killed process).
35
+ exports.REVIEW_HOOK_TIMEOUT_SECONDS = 180;
29
36
  /**
30
37
  * Build the hook command for a sub-dispatcher. We MUST bake
31
38
  * ``FLOWRAIL_MCP_URL=<base>`` in front of the npx invocation —
@@ -51,6 +58,17 @@ function flowrailHookGroups(mcpBaseUrl) {
51
58
  },
52
59
  ];
53
60
  }
61
+ function flowrailReviewGroup(subcommand, mcpBaseUrl) {
62
+ return {
63
+ hooks: [
64
+ {
65
+ type: 'command',
66
+ command: buildHookCommand(subcommand, mcpBaseUrl),
67
+ timeout: exports.REVIEW_HOOK_TIMEOUT_SECONDS,
68
+ },
69
+ ],
70
+ };
71
+ }
54
72
  function isFlowrailGroup(group) {
55
73
  // Identify a group as "ours" if any inner hook command points at the
56
74
  // flowrail-hook binary, regardless of which sub-command. Conservative
@@ -58,16 +76,27 @@ function isFlowrailGroup(group) {
58
76
  // with two flowrail PreToolUse entries every time they re-run init.
59
77
  return group.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_BIN_NAME));
60
78
  }
79
+ function mergeGroups(existing, ours) {
80
+ // Drop any prior flowrail entries before re-inserting so re-running
81
+ // init is idempotent (no duplicate groups piling up); foreign groups
82
+ // are preserved verbatim.
83
+ const others = (existing ?? []).filter((g) => !isFlowrailGroup(g));
84
+ return [...others, ...ours];
85
+ }
61
86
  function mergeClaudeSettings(existing, mcpBaseUrl) {
62
87
  const existingHooks = existing.hooks ?? {};
63
- const existingPreToolUse = existingHooks.PreToolUse ?? [];
64
- // Drop any prior flowrail PreToolUse entries before re-inserting so
65
- // re-running init is idempotent (no duplicate matchers piling up).
66
- const otherGroups = existingPreToolUse.filter((g) => !isFlowrailGroup(g));
67
- const PreToolUse = [...otherGroups, ...flowrailHookGroups(mcpBaseUrl)];
68
88
  return {
69
89
  ...existing,
70
- hooks: { ...existingHooks, PreToolUse },
90
+ hooks: {
91
+ ...existingHooks,
92
+ PreToolUse: mergeGroups(existingHooks.PreToolUse, flowrailHookGroups(mcpBaseUrl)),
93
+ Stop: mergeGroups(existingHooks.Stop, [
94
+ flowrailReviewGroup('stop', mcpBaseUrl),
95
+ ]),
96
+ SessionStart: mergeGroups(existingHooks.SessionStart, [
97
+ flowrailReviewGroup('session-start', mcpBaseUrl),
98
+ ]),
99
+ },
71
100
  };
72
101
  }
73
102
  function writeClaudeSettings(projectRoot, mcpBaseUrl) {