@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.
- package/dist/app-instance.js +287 -0
- package/dist/claude-settings.js +39 -10
- package/dist/doctor-script.js +1292 -7
- package/dist/index.js +0 -0
- package/dist/init.js +21 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/claude-settings.js
CHANGED
|
@@ -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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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: {
|
|
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) {
|