@handsupmin/gc-tree 0.8.13 → 0.8.15
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 +100 -15
- package/dist/src/hook.js +159 -116
- package/dist/src/markdown.js +25 -8
- package/dist/src/resolve.js +79 -35
- package/dist/src/scaffold.js +13 -5
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -86,6 +86,22 @@ function compactMatchCommands(id, home, branch) {
|
|
|
86
86
|
related: `gctree related --id ${JSON.stringify(id)} ${homeArg} ${branchArg}`,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
+
function formatResolveText({ status, gcBranch, currentRepo, matches, message, }) {
|
|
90
|
+
if (status !== 'matched') {
|
|
91
|
+
return `[gc-tree] ${message}\n`;
|
|
92
|
+
}
|
|
93
|
+
const lines = [
|
|
94
|
+
`[gc-tree] 관련 문서 ${matches.length}개 gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}"`,
|
|
95
|
+
];
|
|
96
|
+
for (const match of matches.slice(0, 5)) {
|
|
97
|
+
const keyword = (match.label || match.title || match.id).trim();
|
|
98
|
+
const context = (match.summary || match.excerpt || '').trim();
|
|
99
|
+
const shortContext = context.length > 140 ? `${context.slice(0, 137).trim()}...` : context;
|
|
100
|
+
lines.push(`[${keyword}] 관련 문서 존재${shortContext ? `: ${shortContext}` : ''}`);
|
|
101
|
+
lines.push(`[${keyword}] 세부 정보 탐색: gctree show-doc --id "${match.id}" --branch "${gcBranch}"`);
|
|
102
|
+
}
|
|
103
|
+
return `${lines.join('\n')}\n`;
|
|
104
|
+
}
|
|
89
105
|
function normalizeProvider(value) {
|
|
90
106
|
if (!value)
|
|
91
107
|
return undefined;
|
|
@@ -360,6 +376,7 @@ async function main() {
|
|
|
360
376
|
const currentRepo = resolved.current_repo;
|
|
361
377
|
let scopeStatus = resolved.scope_status;
|
|
362
378
|
const settings = await readSettings(home);
|
|
379
|
+
const jsonOutput = hasFlag('--json');
|
|
363
380
|
if (currentRepo && scopeStatus === 'unmapped') {
|
|
364
381
|
const decision = await promptResolveScopeDecision(gcBranch, currentRepo, settings?.preferred_language || 'English');
|
|
365
382
|
if (decision === 'always-use') {
|
|
@@ -368,19 +385,31 @@ async function main() {
|
|
|
368
385
|
}
|
|
369
386
|
else if (decision === 'ignore') {
|
|
370
387
|
await setRepoScopeForBranch({ home, branch: gcBranch, repo: currentRepo, mode: 'exclude' });
|
|
371
|
-
|
|
388
|
+
const payload = {
|
|
389
|
+
status: 'excluded',
|
|
372
390
|
gc_branch: gcBranch,
|
|
373
391
|
query,
|
|
374
392
|
current_repo: currentRepo,
|
|
375
393
|
source: resolved.source,
|
|
376
394
|
repo_scope_status: 'excluded',
|
|
395
|
+
message: `Repo "${currentRepo}" is excluded from gc-branch "${gcBranch}".`,
|
|
377
396
|
matches: [],
|
|
378
|
-
}
|
|
397
|
+
};
|
|
398
|
+
if (jsonOutput)
|
|
399
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
400
|
+
else
|
|
401
|
+
stdout.write(formatResolveText({
|
|
402
|
+
status: payload.status,
|
|
403
|
+
gcBranch,
|
|
404
|
+
currentRepo,
|
|
405
|
+
matches: [],
|
|
406
|
+
message: payload.message,
|
|
407
|
+
}));
|
|
379
408
|
return;
|
|
380
409
|
}
|
|
381
410
|
}
|
|
382
411
|
if (currentRepo && scopeStatus === 'excluded') {
|
|
383
|
-
|
|
412
|
+
const payload = {
|
|
384
413
|
status: 'excluded',
|
|
385
414
|
gc_branch: gcBranch,
|
|
386
415
|
query,
|
|
@@ -389,12 +418,22 @@ async function main() {
|
|
|
389
418
|
repo_scope_status: 'excluded',
|
|
390
419
|
message: `Repo "${currentRepo}" is excluded from gc-branch "${gcBranch}".`,
|
|
391
420
|
matches: [],
|
|
392
|
-
}
|
|
421
|
+
};
|
|
422
|
+
if (jsonOutput)
|
|
423
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
424
|
+
else
|
|
425
|
+
stdout.write(formatResolveText({
|
|
426
|
+
status: payload.status,
|
|
427
|
+
gcBranch,
|
|
428
|
+
currentRepo,
|
|
429
|
+
matches: [],
|
|
430
|
+
message: payload.message,
|
|
431
|
+
}));
|
|
393
432
|
return;
|
|
394
433
|
}
|
|
395
434
|
const branchStatus = await statusForBranch(home, gcBranch);
|
|
396
435
|
if (branchStatus.doc_count === 0) {
|
|
397
|
-
|
|
436
|
+
const payload = {
|
|
398
437
|
status: 'empty_branch',
|
|
399
438
|
gc_branch: gcBranch,
|
|
400
439
|
query,
|
|
@@ -403,25 +442,49 @@ async function main() {
|
|
|
403
442
|
repo_scope_status: scopeStatus,
|
|
404
443
|
message: `gc-branch "${gcBranch}" has no docs yet. Run gctree onboard to add durable context.`,
|
|
405
444
|
matches: [],
|
|
406
|
-
}
|
|
445
|
+
};
|
|
446
|
+
if (jsonOutput)
|
|
447
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
448
|
+
else
|
|
449
|
+
stdout.write(formatResolveText({
|
|
450
|
+
status: payload.status,
|
|
451
|
+
gcBranch,
|
|
452
|
+
currentRepo,
|
|
453
|
+
matches: [],
|
|
454
|
+
message: payload.message,
|
|
455
|
+
}));
|
|
407
456
|
return;
|
|
408
457
|
}
|
|
409
458
|
const result = await resolveContext({ home, branch: gcBranch, query });
|
|
410
|
-
|
|
411
|
-
|
|
459
|
+
const status = result.matches.length > 0 ? 'matched' : 'no_match';
|
|
460
|
+
const message = result.matches.length > 0
|
|
461
|
+
? `Found ${result.matches.length} matching docs. Use show-doc/related for progressive disclosure.`
|
|
462
|
+
: `No matching docs found in gc-branch "${gcBranch}" for this query.`;
|
|
463
|
+
const payload = {
|
|
464
|
+
status,
|
|
412
465
|
gc_branch: result.branch,
|
|
413
466
|
query: result.query,
|
|
414
467
|
current_repo: currentRepo,
|
|
415
468
|
source: resolved.source,
|
|
416
469
|
repo_scope_status: scopeStatus,
|
|
417
|
-
message
|
|
418
|
-
? `Found ${result.matches.length} matching docs. Use show-doc/related for progressive disclosure.`
|
|
419
|
-
: `No matching docs found in gc-branch "${gcBranch}" for this query.`,
|
|
470
|
+
message,
|
|
420
471
|
matches: result.matches.map((match) => ({
|
|
421
472
|
...match,
|
|
422
473
|
commands: compactMatchCommands(match.id, home, gcBranch),
|
|
423
474
|
})),
|
|
424
|
-
}
|
|
475
|
+
};
|
|
476
|
+
if (jsonOutput) {
|
|
477
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
stdout.write(formatResolveText({
|
|
481
|
+
status,
|
|
482
|
+
gcBranch,
|
|
483
|
+
currentRepo,
|
|
484
|
+
matches: result.matches,
|
|
485
|
+
message,
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
425
488
|
return;
|
|
426
489
|
}
|
|
427
490
|
case 'show-doc': {
|
|
@@ -532,13 +595,35 @@ async function main() {
|
|
|
532
595
|
usage();
|
|
533
596
|
const explicitTarget = readArg('--target');
|
|
534
597
|
const isGlobal = !explicitTarget || Object.values({ codex: gctreeGlobalRoot('codex'), claude: gctreeGlobalRoot('claude-code') }).some((r) => resolve(explicitTarget) === resolve(r));
|
|
535
|
-
|
|
598
|
+
if (isGlobal) {
|
|
599
|
+
const result = await ensureScaffold({
|
|
600
|
+
providerMode: host,
|
|
601
|
+
scope: 'global',
|
|
602
|
+
force: hasFlag('--force'),
|
|
603
|
+
home,
|
|
604
|
+
});
|
|
605
|
+
console.log(JSON.stringify(result, null, 2));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const globalResult = await ensureScaffold({
|
|
536
609
|
providerMode: host,
|
|
537
|
-
|
|
610
|
+
scope: 'global',
|
|
538
611
|
force: hasFlag('--force'),
|
|
539
612
|
home,
|
|
540
613
|
});
|
|
541
|
-
|
|
614
|
+
const localResult = await ensureScaffold({
|
|
615
|
+
providerMode: host,
|
|
616
|
+
targetDir: explicitTarget,
|
|
617
|
+
scope: 'local',
|
|
618
|
+
force: hasFlag('--force'),
|
|
619
|
+
home,
|
|
620
|
+
});
|
|
621
|
+
console.log(JSON.stringify({
|
|
622
|
+
hosts: host,
|
|
623
|
+
target_dir: explicitTarget,
|
|
624
|
+
written: [...globalResult.written, ...localResult.written],
|
|
625
|
+
skipped_existing: [...globalResult.skipped_existing, ...localResult.skipped_existing],
|
|
626
|
+
}, null, 2));
|
|
542
627
|
return;
|
|
543
628
|
}
|
|
544
629
|
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';
|
|
@@ -24,17 +24,21 @@ function recentDuplicate(previousAt, now, windowMs = 5000) {
|
|
|
24
24
|
function limitMatches(matches, max = 3) {
|
|
25
25
|
return matches.slice(0, max);
|
|
26
26
|
}
|
|
27
|
+
function displayKeyword(match) {
|
|
28
|
+
return (match.label || match.title || match.id).trim();
|
|
29
|
+
}
|
|
27
30
|
function formatMatches(matches) {
|
|
28
31
|
return limitMatches(matches)
|
|
29
|
-
.map((match
|
|
32
|
+
.map((match) => {
|
|
30
33
|
const summary = match.summary?.trim() ?? '';
|
|
31
34
|
const excerpt = match.excerpt?.trim() ?? '';
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const context = summary || excerpt;
|
|
36
|
+
const shortContext = context.length > 180 ? `${context.slice(0, 177).trim()}...` : context;
|
|
37
|
+
const showDoc = `gctree show-doc --id "${match.id}"`;
|
|
38
|
+
return [
|
|
39
|
+
`[${displayKeyword(match)}] 관련 문서 존재${shortContext ? `: ${shortContext}` : ''}`,
|
|
40
|
+
`[${displayKeyword(match)}] 세부 정보 탐색: ${showDoc}`,
|
|
41
|
+
].join('\n');
|
|
38
42
|
})
|
|
39
43
|
.join('\n');
|
|
40
44
|
}
|
|
@@ -54,7 +58,7 @@ function buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches, })
|
|
|
54
58
|
const lines = [
|
|
55
59
|
`[gc-tree] USE FIRST: ${Math.min(matches.length, 3)} docs gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}" scope=${repoScopeStatus}.`,
|
|
56
60
|
formatMatches(matches),
|
|
57
|
-
`Rule:
|
|
61
|
+
`Rule: 위 문서를 먼저 근거로 삼고, 부족하면 표시된 show-doc 명령으로 세부 정보를 확인.`,
|
|
58
62
|
];
|
|
59
63
|
if (repoScopeStatus === 'unmapped' && currentRepo) {
|
|
60
64
|
lines.push(`Note: repo "${currentRepo}" is unmapped. If it belongs here, offer to run: gctree set-repo-scope --branch ${gcBranch} --include`);
|
|
@@ -107,127 +111,166 @@ function readSessionId(payload) {
|
|
|
107
111
|
const raw = normalizeText(payload.session_id || '');
|
|
108
112
|
return raw || 'default-session';
|
|
109
113
|
}
|
|
114
|
+
function sleep(ms) {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
117
|
+
async function acquireHookLock(home, sessionId) {
|
|
118
|
+
const lockPath = `${hookCachePath(home, sessionId)}.lock`;
|
|
119
|
+
await mkdir(hookCacheDir(home), { recursive: true });
|
|
120
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
121
|
+
try {
|
|
122
|
+
const handle = await open(lockPath, 'wx');
|
|
123
|
+
await handle.close();
|
|
124
|
+
return async () => {
|
|
125
|
+
await rm(lockPath, { force: true });
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const code = error.code;
|
|
130
|
+
if (code !== 'EEXIST')
|
|
131
|
+
throw error;
|
|
132
|
+
try {
|
|
133
|
+
const info = await stat(lockPath);
|
|
134
|
+
if (Date.now() - info.mtimeMs > 10_000) {
|
|
135
|
+
await rm(lockPath, { force: true });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
await sleep(25);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return async () => { };
|
|
146
|
+
}
|
|
110
147
|
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
148
|
const sessionId = readSessionId(payload);
|
|
119
|
-
const
|
|
120
|
-
|
|
149
|
+
const releaseLock = await acquireHookLock(home, sessionId);
|
|
150
|
+
try {
|
|
151
|
+
const cwd = payload.cwd || process.cwd();
|
|
152
|
+
const head = (await readHead(home)) || DEFAULT_BRANCH;
|
|
153
|
+
const resolved = await resolveBranchForRepo({ home, head, cwd });
|
|
154
|
+
const gcBranch = resolved.gc_branch;
|
|
155
|
+
const currentRepo = resolved.current_repo;
|
|
156
|
+
const mapping = await readBranchRepoMap(home);
|
|
157
|
+
const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
|
|
158
|
+
const now = new Date();
|
|
159
|
+
if (event === 'SessionStart') {
|
|
160
|
+
const previousCache = await readHookCache(home, sessionId);
|
|
161
|
+
const signature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}`);
|
|
162
|
+
if (previousCache?.last_session_start_signature === signature &&
|
|
163
|
+
recentDuplicate(previousCache.last_session_start_at, now)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
await writeHookCache(home, {
|
|
167
|
+
version: 1,
|
|
168
|
+
session_id: sessionId,
|
|
169
|
+
gc_branch: gcBranch,
|
|
170
|
+
current_repo: currentRepo,
|
|
171
|
+
repo_scope_status: repoScopeStatus,
|
|
172
|
+
branch_empty: previousCache?.branch_empty ?? false,
|
|
173
|
+
branch_excluded: previousCache?.branch_excluded ?? false,
|
|
174
|
+
no_match_signatures: previousCache?.no_match_signatures ?? [],
|
|
175
|
+
unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
|
|
176
|
+
prompt_count: previousCache?.prompt_count ?? 0,
|
|
177
|
+
last_session_start_signature: signature,
|
|
178
|
+
last_session_start_at: now.toISOString(),
|
|
179
|
+
last_prompt_signature: previousCache?.last_prompt_signature,
|
|
180
|
+
last_prompt_at: previousCache?.last_prompt_at,
|
|
181
|
+
updated_at: now.toISOString(),
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
hookSpecificOutput: {
|
|
185
|
+
hookEventName: event,
|
|
186
|
+
additionalContext: buildSessionStartContext({
|
|
187
|
+
gcBranch,
|
|
188
|
+
currentRepo,
|
|
189
|
+
repoScopeStatus,
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
|
|
195
|
+
if (!prompt)
|
|
196
|
+
return null;
|
|
197
|
+
const branchStatus = await statusForBranch(home, gcBranch);
|
|
121
198
|
const previousCache = await readHookCache(home, sessionId);
|
|
122
|
-
const
|
|
123
|
-
if (previousCache?.
|
|
124
|
-
recentDuplicate(previousCache.
|
|
199
|
+
const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
|
|
200
|
+
if (previousCache?.last_prompt_signature === promptSignature &&
|
|
201
|
+
recentDuplicate(previousCache.last_prompt_at, now)) {
|
|
125
202
|
return null;
|
|
126
203
|
}
|
|
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({
|
|
204
|
+
const cache = nextCacheState(previousCache, {
|
|
205
|
+
sessionId,
|
|
177
206
|
gcBranch,
|
|
178
207
|
currentRepo,
|
|
179
|
-
|
|
208
|
+
repoScopeStatus,
|
|
180
209
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
210
|
+
cache.last_prompt_signature = promptSignature;
|
|
211
|
+
cache.last_prompt_at = now.toISOString();
|
|
212
|
+
let additionalContext;
|
|
213
|
+
if (repoScopeStatus === 'excluded') {
|
|
214
|
+
cache.branch_excluded = true;
|
|
215
|
+
additionalContext = buildExcludedContext({
|
|
216
|
+
gcBranch,
|
|
217
|
+
currentRepo,
|
|
218
|
+
cached: previousCache?.branch_excluded === true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
else if (branchStatus.doc_count === 0) {
|
|
222
|
+
const wasCached = cache.branch_empty;
|
|
223
|
+
cache.branch_empty = true;
|
|
224
|
+
additionalContext = buildEmptyBranchContext({ gcBranch, currentRepo, cached: wasCached });
|
|
225
|
+
}
|
|
226
|
+
else if (repoScopeStatus === 'unmapped' && currentRepo && cache.unmapped_shown_repos.includes(currentRepo)) {
|
|
227
|
+
// Already showed context for this unmapped repo this session — skip silently.
|
|
228
|
+
cache.updated_at = now.toISOString();
|
|
229
|
+
await writeHookCache(home, cache);
|
|
230
|
+
return null;
|
|
200
231
|
}
|
|
201
232
|
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 });
|
|
233
|
+
// For unmapped repos: use prompt only (no repo prefix) to avoid bias; apply higher score threshold.
|
|
234
|
+
const isUnmapped = repoScopeStatus === 'unmapped';
|
|
235
|
+
const query = !isUnmapped && currentRepo ? `${currentRepo} ${prompt}` : prompt;
|
|
236
|
+
const signature = hashQuery(query);
|
|
237
|
+
if (!isUnmapped && cache.no_match_signatures.includes(signature)) {
|
|
238
|
+
additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: true });
|
|
213
239
|
}
|
|
214
240
|
else {
|
|
215
|
-
|
|
216
|
-
|
|
241
|
+
const result = await resolveContext({ home, branch: gcBranch, query });
|
|
242
|
+
const effectiveMatches = result.matches;
|
|
243
|
+
if (effectiveMatches.length === 0) {
|
|
244
|
+
if (isUnmapped) {
|
|
245
|
+
// Unmapped + no strong match: skip silently, no noise.
|
|
246
|
+
cache.updated_at = now.toISOString();
|
|
247
|
+
await writeHookCache(home, cache);
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
|
|
251
|
+
additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: false });
|
|
217
252
|
}
|
|
218
|
-
else
|
|
219
|
-
|
|
220
|
-
|
|
253
|
+
else {
|
|
254
|
+
if (!isUnmapped) {
|
|
255
|
+
cache.no_match_signatures = cache.no_match_signatures.filter((entry) => entry !== signature);
|
|
256
|
+
}
|
|
257
|
+
else if (currentRepo) {
|
|
258
|
+
// Mark this unmapped repo as shown so we don't repeat next prompt.
|
|
259
|
+
cache.unmapped_shown_repos = [...new Set([...cache.unmapped_shown_repos, currentRepo])];
|
|
260
|
+
}
|
|
261
|
+
additionalContext = buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches: effectiveMatches });
|
|
221
262
|
}
|
|
222
|
-
|
|
263
|
+
}
|
|
264
|
+
if (cache.prompt_count > 0 && cache.prompt_count % SELF_REVIEW_INTERVAL === 0) {
|
|
265
|
+
additionalContext += buildSelfReviewAppend(cache.prompt_count);
|
|
266
|
+
cache.prompt_count = 0;
|
|
223
267
|
}
|
|
224
268
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
269
|
+
cache.updated_at = now.toISOString();
|
|
270
|
+
await writeHookCache(home, cache);
|
|
271
|
+
return { hookSpecificOutput: { hookEventName: event, additionalContext } };
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await releaseLock();
|
|
229
275
|
}
|
|
230
|
-
cache.updated_at = now.toISOString();
|
|
231
|
-
await writeHookCache(home, cache);
|
|
232
|
-
return { hookSpecificOutput: { hookEventName: event, additionalContext } };
|
|
233
276
|
}
|
package/dist/src/markdown.js
CHANGED
|
@@ -295,14 +295,31 @@ export function extractTitle(markdown) {
|
|
|
295
295
|
return match?.[1]?.trim() || '';
|
|
296
296
|
}
|
|
297
297
|
export function extractExcerpt(markdown, query) {
|
|
298
|
-
const content = String(markdown || '')
|
|
298
|
+
const content = String(markdown || '')
|
|
299
|
+
.replace(/^#+\s+/gm, '')
|
|
300
|
+
.replace(/\s+/g, ' ')
|
|
301
|
+
.trim();
|
|
299
302
|
if (!content)
|
|
300
303
|
return '';
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
const tokens = String(query || '')
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
307
|
+
.flatMap((t) => t.split(/(?<=[a-z0-9])(?=[^\x00-\x7f])|(?<=[^\x00-\x7f])(?=[a-z0-9])/))
|
|
308
|
+
.filter((t) => t.length >= 2);
|
|
309
|
+
if (tokens.length === 0)
|
|
310
|
+
return content.slice(0, 180);
|
|
311
|
+
const chunks = content
|
|
312
|
+
.split(/(?<=[.!?。!?])\s+|\s+-\s+|(?=\b[A-Z][A-Za-z ]+:)/)
|
|
313
|
+
.map((chunk) => chunk.trim())
|
|
314
|
+
.filter(Boolean);
|
|
315
|
+
const scored = chunks
|
|
316
|
+
.map((chunk, index) => ({
|
|
317
|
+
chunk,
|
|
318
|
+
index,
|
|
319
|
+
score: tokens.reduce((sum, token) => sum + (chunk.toLowerCase().includes(token) ? 1 : 0), 0),
|
|
320
|
+
}))
|
|
321
|
+
.filter((entry) => entry.score > 0)
|
|
322
|
+
.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
323
|
+
const best = scored[0]?.chunk || content;
|
|
324
|
+
return best.length > 220 ? `${best.slice(0, 217).trim()}...` : best;
|
|
308
325
|
}
|
package/dist/src/resolve.js
CHANGED
|
@@ -27,8 +27,7 @@ function makeTokenRegex(token) {
|
|
|
27
27
|
? new RegExp(`\\b${token}\\b`)
|
|
28
28
|
: new RegExp(`(?<!\\p{L})${token}(?!\\p{L})`, 'u');
|
|
29
29
|
}
|
|
30
|
-
function
|
|
31
|
-
const tokens = tokenize(query);
|
|
30
|
+
function countTokenMatches(text, tokens) {
|
|
32
31
|
if (tokens.length === 0)
|
|
33
32
|
return 0;
|
|
34
33
|
const haystack = String(text || '').toLowerCase();
|
|
@@ -42,13 +41,58 @@ function scoreText(text, query) {
|
|
|
42
41
|
}
|
|
43
42
|
}, 0);
|
|
44
43
|
}
|
|
44
|
+
function scoreText(text, query) {
|
|
45
|
+
return countTokenMatches(text, tokenize(query));
|
|
46
|
+
}
|
|
47
|
+
function exactPhraseScore(text, query) {
|
|
48
|
+
const phrase = String(query || '').trim().toLowerCase();
|
|
49
|
+
if (phrase.length < 4)
|
|
50
|
+
return 0;
|
|
51
|
+
return String(text || '').toLowerCase().includes(phrase) ? 6 : 0;
|
|
52
|
+
}
|
|
53
|
+
function scoreDoc(entry, query) {
|
|
54
|
+
const tokens = tokenize(query);
|
|
55
|
+
if (tokens.length === 0)
|
|
56
|
+
return 0;
|
|
57
|
+
const labelScore = countTokenMatches(entry.label, tokens) * 10;
|
|
58
|
+
const titleScore = countTokenMatches(entry.title, tokens) * 7;
|
|
59
|
+
const summaryScore = countTokenMatches(entry.summary, tokens) * 5;
|
|
60
|
+
const categoryScore = countTokenMatches(entry.category, tokens) * 2;
|
|
61
|
+
const pathScore = countTokenMatches(entry.path, tokens) * 2;
|
|
62
|
+
const contentScore = countTokenMatches(entry.content, tokens);
|
|
63
|
+
const phraseScore = exactPhraseScore(entry.label, query) +
|
|
64
|
+
exactPhraseScore(entry.title, query) +
|
|
65
|
+
exactPhraseScore(entry.summary, query) +
|
|
66
|
+
exactPhraseScore(entry.content, query);
|
|
67
|
+
return labelScore + titleScore + summaryScore + categoryScore + pathScore + contentScore + phraseScore;
|
|
68
|
+
}
|
|
69
|
+
function minimumUsefulScore(query) {
|
|
70
|
+
return tokenize(query).length <= 1 ? 1 : 2;
|
|
71
|
+
}
|
|
72
|
+
function labelQueryPosition(label, query) {
|
|
73
|
+
const normalizedQuery = String(query || '').toLowerCase();
|
|
74
|
+
const positions = tokenize(label)
|
|
75
|
+
.map((token) => normalizedQuery.indexOf(token))
|
|
76
|
+
.filter((index) => index >= 0);
|
|
77
|
+
return positions.length > 0 ? Math.min(...positions) : Number.MAX_SAFE_INTEGER;
|
|
78
|
+
}
|
|
79
|
+
function betterMatch(candidate, previous, query) {
|
|
80
|
+
return (!previous ||
|
|
81
|
+
candidate.score > previous.score ||
|
|
82
|
+
(candidate.score === previous.score &&
|
|
83
|
+
labelQueryPosition(candidate.label, query) < labelQueryPosition(previous.label, query)));
|
|
84
|
+
}
|
|
45
85
|
async function readBranchDocs(home, branch) {
|
|
46
86
|
const indexRaw = await readFile(branchIndexPath(home, branch), 'utf8');
|
|
47
87
|
const entries = parseIndexEntries(indexRaw);
|
|
48
|
-
const
|
|
49
|
-
return Promise.all(
|
|
88
|
+
const contentCache = new Map();
|
|
89
|
+
return Promise.all(entries.map(async (entry) => {
|
|
50
90
|
const fullPath = join(branchDir(home, branch), entry.path);
|
|
51
|
-
|
|
91
|
+
let raw = contentCache.get(fullPath);
|
|
92
|
+
if (!raw) {
|
|
93
|
+
raw = await readFile(fullPath, 'utf8');
|
|
94
|
+
contentCache.set(fullPath, raw);
|
|
95
|
+
}
|
|
52
96
|
return {
|
|
53
97
|
id: entry.id || docIdFromPath(entry.path),
|
|
54
98
|
title: extractTitle(raw) || entry.title,
|
|
@@ -62,16 +106,13 @@ async function readBranchDocs(home, branch) {
|
|
|
62
106
|
}
|
|
63
107
|
export async function resolveContext({ home, branch, query, }) {
|
|
64
108
|
const entries = await readBranchDocs(home, branch);
|
|
65
|
-
const
|
|
109
|
+
const matchesById = new Map();
|
|
110
|
+
const minScore = minimumUsefulScore(query);
|
|
66
111
|
for (const entry of entries) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const titleScore = scoreText(entry.title, query);
|
|
70
|
-
const bodyScore = scoreText(`${entry.summary}\n${entry.content}`, query);
|
|
71
|
-
const score = titleScore * 2 + bodyScore;
|
|
72
|
-
if (score <= 0)
|
|
112
|
+
const score = scoreDoc(entry, query);
|
|
113
|
+
if (score < minScore)
|
|
73
114
|
continue;
|
|
74
|
-
|
|
115
|
+
const match = {
|
|
75
116
|
id: entry.id,
|
|
76
117
|
label: entry.label,
|
|
77
118
|
category: entry.category,
|
|
@@ -80,12 +121,15 @@ export async function resolveContext({ home, branch, query, }) {
|
|
|
80
121
|
score,
|
|
81
122
|
summary: entry.summary,
|
|
82
123
|
excerpt: extractExcerpt(entry.content, query),
|
|
83
|
-
}
|
|
124
|
+
};
|
|
125
|
+
if (betterMatch(match, matchesById.get(match.id), query)) {
|
|
126
|
+
matchesById.set(match.id, match);
|
|
127
|
+
}
|
|
84
128
|
}
|
|
85
129
|
return {
|
|
86
130
|
branch,
|
|
87
131
|
query,
|
|
88
|
-
matches:
|
|
132
|
+
matches: [...matchesById.values()].sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)),
|
|
89
133
|
};
|
|
90
134
|
}
|
|
91
135
|
export async function getDocById({ home, branch, id, }) {
|
|
@@ -102,26 +146,26 @@ export async function findRelatedDocs({ home, branch, id, limit = 5, }) {
|
|
|
102
146
|
return { status: 'doc_not_found', selected: null, matches: [] };
|
|
103
147
|
}
|
|
104
148
|
const query = `${selected.title}\n${selected.summary}`;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
149
|
+
const matchesById = new Map();
|
|
150
|
+
for (const doc of docs.filter((doc) => doc.id !== selected.id)) {
|
|
151
|
+
const score = scoreDoc(doc, query);
|
|
152
|
+
if (score <= 0)
|
|
153
|
+
continue;
|
|
154
|
+
const match = {
|
|
155
|
+
id: doc.id,
|
|
156
|
+
label: doc.label,
|
|
157
|
+
category: doc.category,
|
|
158
|
+
title: doc.title,
|
|
159
|
+
path: doc.path,
|
|
160
|
+
score,
|
|
161
|
+
summary: doc.summary,
|
|
162
|
+
excerpt: extractExcerpt(doc.content, query),
|
|
163
|
+
};
|
|
164
|
+
if (betterMatch(match, matchesById.get(match.id), query)) {
|
|
165
|
+
matchesById.set(match.id, match);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const matches = [...matchesById.values()]
|
|
125
169
|
.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
|
|
126
170
|
.slice(0, limit);
|
|
127
171
|
return {
|
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),
|