@handsupmin/gc-tree 0.8.13 → 0.8.14
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/src/cli.js +25 -3
- package/dist/src/hook.js +147 -108
- package/dist/src/scaffold.js +13 -5
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -532,13 +532,35 @@ async function main() {
|
|
|
532
532
|
usage();
|
|
533
533
|
const explicitTarget = readArg('--target');
|
|
534
534
|
const isGlobal = !explicitTarget || Object.values({ codex: gctreeGlobalRoot('codex'), claude: gctreeGlobalRoot('claude-code') }).some((r) => resolve(explicitTarget) === resolve(r));
|
|
535
|
-
|
|
535
|
+
if (isGlobal) {
|
|
536
|
+
const result = await ensureScaffold({
|
|
537
|
+
providerMode: host,
|
|
538
|
+
scope: 'global',
|
|
539
|
+
force: hasFlag('--force'),
|
|
540
|
+
home,
|
|
541
|
+
});
|
|
542
|
+
console.log(JSON.stringify(result, null, 2));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const globalResult = await ensureScaffold({
|
|
536
546
|
providerMode: host,
|
|
537
|
-
|
|
547
|
+
scope: 'global',
|
|
538
548
|
force: hasFlag('--force'),
|
|
539
549
|
home,
|
|
540
550
|
});
|
|
541
|
-
|
|
551
|
+
const localResult = await ensureScaffold({
|
|
552
|
+
providerMode: host,
|
|
553
|
+
targetDir: explicitTarget,
|
|
554
|
+
scope: 'local',
|
|
555
|
+
force: hasFlag('--force'),
|
|
556
|
+
home,
|
|
557
|
+
});
|
|
558
|
+
console.log(JSON.stringify({
|
|
559
|
+
hosts: host,
|
|
560
|
+
target_dir: explicitTarget,
|
|
561
|
+
written: [...globalResult.written, ...localResult.written],
|
|
562
|
+
skipped_existing: [...globalResult.skipped_existing, ...localResult.skipped_existing],
|
|
563
|
+
}, null, 2));
|
|
542
564
|
return;
|
|
543
565
|
}
|
|
544
566
|
case 'update': {
|
package/dist/src/hook.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { DEFAULT_BRANCH } from './paths.js';
|
|
5
|
-
import { hookCachePath, } from './paths.js';
|
|
5
|
+
import { hookCachePath, hookCacheDir, } from './paths.js';
|
|
6
6
|
import { branchScopeStatus, readBranchRepoMap, resolveBranchForRepo } from './repo-map.js';
|
|
7
7
|
import { resolveContext } from './resolve.js';
|
|
8
8
|
import { readHead, statusForBranch } from './store.js';
|
|
@@ -107,127 +107,166 @@ function readSessionId(payload) {
|
|
|
107
107
|
const raw = normalizeText(payload.session_id || '');
|
|
108
108
|
return raw || 'default-session';
|
|
109
109
|
}
|
|
110
|
+
function sleep(ms) {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
async function acquireHookLock(home, sessionId) {
|
|
114
|
+
const lockPath = `${hookCachePath(home, sessionId)}.lock`;
|
|
115
|
+
await mkdir(hookCacheDir(home), { recursive: true });
|
|
116
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
117
|
+
try {
|
|
118
|
+
const handle = await open(lockPath, 'wx');
|
|
119
|
+
await handle.close();
|
|
120
|
+
return async () => {
|
|
121
|
+
await rm(lockPath, { force: true });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const code = error.code;
|
|
126
|
+
if (code !== 'EEXIST')
|
|
127
|
+
throw error;
|
|
128
|
+
try {
|
|
129
|
+
const info = await stat(lockPath);
|
|
130
|
+
if (Date.now() - info.mtimeMs > 10_000) {
|
|
131
|
+
await rm(lockPath, { force: true });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
await sleep(25);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return async () => { };
|
|
142
|
+
}
|
|
110
143
|
export async function dispatchGcTreeHook({ event, home, payload, }) {
|
|
111
|
-
const cwd = payload.cwd || process.cwd();
|
|
112
|
-
const head = (await readHead(home)) || DEFAULT_BRANCH;
|
|
113
|
-
const resolved = await resolveBranchForRepo({ home, head, cwd });
|
|
114
|
-
const gcBranch = resolved.gc_branch;
|
|
115
|
-
const currentRepo = resolved.current_repo;
|
|
116
|
-
const mapping = await readBranchRepoMap(home);
|
|
117
|
-
const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
|
|
118
144
|
const sessionId = readSessionId(payload);
|
|
119
|
-
const
|
|
120
|
-
|
|
145
|
+
const releaseLock = await acquireHookLock(home, sessionId);
|
|
146
|
+
try {
|
|
147
|
+
const cwd = payload.cwd || process.cwd();
|
|
148
|
+
const head = (await readHead(home)) || DEFAULT_BRANCH;
|
|
149
|
+
const resolved = await resolveBranchForRepo({ home, head, cwd });
|
|
150
|
+
const gcBranch = resolved.gc_branch;
|
|
151
|
+
const currentRepo = resolved.current_repo;
|
|
152
|
+
const mapping = await readBranchRepoMap(home);
|
|
153
|
+
const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
|
|
154
|
+
const now = new Date();
|
|
155
|
+
if (event === 'SessionStart') {
|
|
156
|
+
const previousCache = await readHookCache(home, sessionId);
|
|
157
|
+
const signature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}`);
|
|
158
|
+
if (previousCache?.last_session_start_signature === signature &&
|
|
159
|
+
recentDuplicate(previousCache.last_session_start_at, now)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
await writeHookCache(home, {
|
|
163
|
+
version: 1,
|
|
164
|
+
session_id: sessionId,
|
|
165
|
+
gc_branch: gcBranch,
|
|
166
|
+
current_repo: currentRepo,
|
|
167
|
+
repo_scope_status: repoScopeStatus,
|
|
168
|
+
branch_empty: previousCache?.branch_empty ?? false,
|
|
169
|
+
branch_excluded: previousCache?.branch_excluded ?? false,
|
|
170
|
+
no_match_signatures: previousCache?.no_match_signatures ?? [],
|
|
171
|
+
unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
|
|
172
|
+
prompt_count: previousCache?.prompt_count ?? 0,
|
|
173
|
+
last_session_start_signature: signature,
|
|
174
|
+
last_session_start_at: now.toISOString(),
|
|
175
|
+
last_prompt_signature: previousCache?.last_prompt_signature,
|
|
176
|
+
last_prompt_at: previousCache?.last_prompt_at,
|
|
177
|
+
updated_at: now.toISOString(),
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
hookSpecificOutput: {
|
|
181
|
+
hookEventName: event,
|
|
182
|
+
additionalContext: buildSessionStartContext({
|
|
183
|
+
gcBranch,
|
|
184
|
+
currentRepo,
|
|
185
|
+
repoScopeStatus,
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
|
|
191
|
+
if (!prompt)
|
|
192
|
+
return null;
|
|
193
|
+
const branchStatus = await statusForBranch(home, gcBranch);
|
|
121
194
|
const previousCache = await readHookCache(home, sessionId);
|
|
122
|
-
const
|
|
123
|
-
if (previousCache?.
|
|
124
|
-
recentDuplicate(previousCache.
|
|
195
|
+
const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
|
|
196
|
+
if (previousCache?.last_prompt_signature === promptSignature &&
|
|
197
|
+
recentDuplicate(previousCache.last_prompt_at, now)) {
|
|
125
198
|
return null;
|
|
126
199
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
session_id: sessionId,
|
|
130
|
-
gc_branch: gcBranch,
|
|
131
|
-
current_repo: currentRepo,
|
|
132
|
-
repo_scope_status: repoScopeStatus,
|
|
133
|
-
branch_empty: previousCache?.branch_empty ?? false,
|
|
134
|
-
branch_excluded: previousCache?.branch_excluded ?? false,
|
|
135
|
-
no_match_signatures: previousCache?.no_match_signatures ?? [],
|
|
136
|
-
unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
|
|
137
|
-
prompt_count: previousCache?.prompt_count ?? 0,
|
|
138
|
-
last_session_start_signature: signature,
|
|
139
|
-
last_session_start_at: now.toISOString(),
|
|
140
|
-
last_prompt_signature: previousCache?.last_prompt_signature,
|
|
141
|
-
last_prompt_at: previousCache?.last_prompt_at,
|
|
142
|
-
updated_at: now.toISOString(),
|
|
143
|
-
});
|
|
144
|
-
return {
|
|
145
|
-
hookSpecificOutput: {
|
|
146
|
-
hookEventName: event,
|
|
147
|
-
additionalContext: buildSessionStartContext({
|
|
148
|
-
gcBranch,
|
|
149
|
-
currentRepo,
|
|
150
|
-
repoScopeStatus,
|
|
151
|
-
}),
|
|
152
|
-
},
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
|
|
156
|
-
if (!prompt)
|
|
157
|
-
return null;
|
|
158
|
-
const branchStatus = await statusForBranch(home, gcBranch);
|
|
159
|
-
const previousCache = await readHookCache(home, sessionId);
|
|
160
|
-
const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
|
|
161
|
-
if (previousCache?.last_prompt_signature === promptSignature &&
|
|
162
|
-
recentDuplicate(previousCache.last_prompt_at, now)) {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
const cache = nextCacheState(previousCache, {
|
|
166
|
-
sessionId,
|
|
167
|
-
gcBranch,
|
|
168
|
-
currentRepo,
|
|
169
|
-
repoScopeStatus,
|
|
170
|
-
});
|
|
171
|
-
cache.last_prompt_signature = promptSignature;
|
|
172
|
-
cache.last_prompt_at = now.toISOString();
|
|
173
|
-
let additionalContext;
|
|
174
|
-
if (repoScopeStatus === 'excluded') {
|
|
175
|
-
cache.branch_excluded = true;
|
|
176
|
-
additionalContext = buildExcludedContext({
|
|
200
|
+
const cache = nextCacheState(previousCache, {
|
|
201
|
+
sessionId,
|
|
177
202
|
gcBranch,
|
|
178
203
|
currentRepo,
|
|
179
|
-
|
|
204
|
+
repoScopeStatus,
|
|
180
205
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
cache.last_prompt_signature = promptSignature;
|
|
207
|
+
cache.last_prompt_at = now.toISOString();
|
|
208
|
+
let additionalContext;
|
|
209
|
+
if (repoScopeStatus === 'excluded') {
|
|
210
|
+
cache.branch_excluded = true;
|
|
211
|
+
additionalContext = buildExcludedContext({
|
|
212
|
+
gcBranch,
|
|
213
|
+
currentRepo,
|
|
214
|
+
cached: previousCache?.branch_excluded === true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else if (branchStatus.doc_count === 0) {
|
|
218
|
+
const wasCached = cache.branch_empty;
|
|
219
|
+
cache.branch_empty = true;
|
|
220
|
+
additionalContext = buildEmptyBranchContext({ gcBranch, currentRepo, cached: wasCached });
|
|
221
|
+
}
|
|
222
|
+
else if (repoScopeStatus === 'unmapped' && currentRepo && cache.unmapped_shown_repos.includes(currentRepo)) {
|
|
223
|
+
// Already showed context for this unmapped repo this session — skip silently.
|
|
224
|
+
cache.updated_at = now.toISOString();
|
|
225
|
+
await writeHookCache(home, cache);
|
|
226
|
+
return null;
|
|
200
227
|
}
|
|
201
228
|
else {
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await writeHookCache(home, cache);
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
|
|
212
|
-
additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: false });
|
|
229
|
+
// For unmapped repos: use prompt only (no repo prefix) to avoid bias; apply higher score threshold.
|
|
230
|
+
const isUnmapped = repoScopeStatus === 'unmapped';
|
|
231
|
+
const query = !isUnmapped && currentRepo ? `${currentRepo} ${prompt}` : prompt;
|
|
232
|
+
const signature = hashQuery(query);
|
|
233
|
+
if (!isUnmapped && cache.no_match_signatures.includes(signature)) {
|
|
234
|
+
additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: true });
|
|
213
235
|
}
|
|
214
236
|
else {
|
|
215
|
-
|
|
216
|
-
|
|
237
|
+
const result = await resolveContext({ home, branch: gcBranch, query });
|
|
238
|
+
const effectiveMatches = result.matches;
|
|
239
|
+
if (effectiveMatches.length === 0) {
|
|
240
|
+
if (isUnmapped) {
|
|
241
|
+
// Unmapped + no strong match: skip silently, no noise.
|
|
242
|
+
cache.updated_at = now.toISOString();
|
|
243
|
+
await writeHookCache(home, cache);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
|
|
247
|
+
additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: false });
|
|
217
248
|
}
|
|
218
|
-
else
|
|
219
|
-
|
|
220
|
-
|
|
249
|
+
else {
|
|
250
|
+
if (!isUnmapped) {
|
|
251
|
+
cache.no_match_signatures = cache.no_match_signatures.filter((entry) => entry !== signature);
|
|
252
|
+
}
|
|
253
|
+
else if (currentRepo) {
|
|
254
|
+
// Mark this unmapped repo as shown so we don't repeat next prompt.
|
|
255
|
+
cache.unmapped_shown_repos = [...new Set([...cache.unmapped_shown_repos, currentRepo])];
|
|
256
|
+
}
|
|
257
|
+
additionalContext = buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches: effectiveMatches });
|
|
221
258
|
}
|
|
222
|
-
|
|
259
|
+
}
|
|
260
|
+
if (cache.prompt_count > 0 && cache.prompt_count % SELF_REVIEW_INTERVAL === 0) {
|
|
261
|
+
additionalContext += buildSelfReviewAppend(cache.prompt_count);
|
|
262
|
+
cache.prompt_count = 0;
|
|
223
263
|
}
|
|
224
264
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
265
|
+
cache.updated_at = now.toISOString();
|
|
266
|
+
await writeHookCache(home, cache);
|
|
267
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext } };
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await releaseLock();
|
|
229
271
|
}
|
|
230
|
-
cache.updated_at = now.toISOString();
|
|
231
|
-
await writeHookCache(home, cache);
|
|
232
|
-
return { hookSpecificOutput: { hookEventName: event, additionalContext } };
|
|
233
272
|
}
|
package/dist/src/scaffold.js
CHANGED
|
@@ -301,11 +301,19 @@ export async function scaffoldHostIntegration({ host, targetDir, force = false,
|
|
|
301
301
|
await unmergeGcTreeHooksJson(oldHooksPath);
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
304
|
+
if (scope === 'local') {
|
|
305
|
+
const status = await unmergeGcTreeHooksJson(hookPath);
|
|
306
|
+
if (status !== 'missing') {
|
|
307
|
+
written.push(hookPath);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
await mergeGcTreeHooksJson({
|
|
312
|
+
filePath: hookPath,
|
|
313
|
+
target: isCodex ? 'codex' : 'claude-code',
|
|
314
|
+
});
|
|
315
|
+
written.push(hookPath);
|
|
316
|
+
}
|
|
309
317
|
const targets = files.map((file) => ({
|
|
310
318
|
...file,
|
|
311
319
|
fullPath: join(resolvedTargetDir, file.path),
|