@handsupmin/gc-tree 0.8.12 → 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 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
- const result = await ensureScaffold({
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
- ...(isGlobal ? { scope: 'global' } : { targetDir: explicitTarget, scope: 'local' }),
547
+ scope: 'global',
538
548
  force: hasFlag('--force'),
539
549
  home,
540
550
  });
541
- console.log(JSON.stringify(result, null, 2));
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';
@@ -27,9 +27,13 @@ function limitMatches(matches, max = 3) {
27
27
  function formatMatches(matches) {
28
28
  return limitMatches(matches)
29
29
  .map((match, index) => {
30
- const summary = match.summary?.trim();
31
- return summary
32
- ? `${index + 1}. ${match.title} [${match.id}]\n > ${summary}`
30
+ const summary = match.summary?.trim() ?? '';
31
+ const excerpt = match.excerpt?.trim() ?? '';
32
+ // Use summary if it looks actionable (long enough to contain patterns).
33
+ // Fall back to a trimmed excerpt when summary is a short description-only sentence.
34
+ const context = summary.length >= 60 ? summary : (excerpt.slice(0, 300) || summary);
35
+ return context
36
+ ? `${index + 1}. ${match.title} [${match.id}]\n > ${context}`
33
37
  : `${index + 1}. ${match.title} [${match.id}]`;
34
38
  })
35
39
  .join('\n');
@@ -103,127 +107,166 @@ function readSessionId(payload) {
103
107
  const raw = normalizeText(payload.session_id || '');
104
108
  return raw || 'default-session';
105
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
+ }
106
143
  export async function dispatchGcTreeHook({ event, home, payload, }) {
107
- const cwd = payload.cwd || process.cwd();
108
- const head = (await readHead(home)) || DEFAULT_BRANCH;
109
- const resolved = await resolveBranchForRepo({ home, head, cwd });
110
- const gcBranch = resolved.gc_branch;
111
- const currentRepo = resolved.current_repo;
112
- const mapping = await readBranchRepoMap(home);
113
- const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
114
144
  const sessionId = readSessionId(payload);
115
- const now = new Date();
116
- if (event === 'SessionStart') {
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);
117
194
  const previousCache = await readHookCache(home, sessionId);
118
- const signature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}`);
119
- if (previousCache?.last_session_start_signature === signature &&
120
- recentDuplicate(previousCache.last_session_start_at, now)) {
195
+ const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
196
+ if (previousCache?.last_prompt_signature === promptSignature &&
197
+ recentDuplicate(previousCache.last_prompt_at, now)) {
121
198
  return null;
122
199
  }
123
- await writeHookCache(home, {
124
- version: 1,
125
- session_id: sessionId,
126
- gc_branch: gcBranch,
127
- current_repo: currentRepo,
128
- repo_scope_status: repoScopeStatus,
129
- branch_empty: previousCache?.branch_empty ?? false,
130
- branch_excluded: previousCache?.branch_excluded ?? false,
131
- no_match_signatures: previousCache?.no_match_signatures ?? [],
132
- unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
133
- prompt_count: previousCache?.prompt_count ?? 0,
134
- last_session_start_signature: signature,
135
- last_session_start_at: now.toISOString(),
136
- last_prompt_signature: previousCache?.last_prompt_signature,
137
- last_prompt_at: previousCache?.last_prompt_at,
138
- updated_at: now.toISOString(),
139
- });
140
- return {
141
- hookSpecificOutput: {
142
- hookEventName: event,
143
- additionalContext: buildSessionStartContext({
144
- gcBranch,
145
- currentRepo,
146
- repoScopeStatus,
147
- }),
148
- },
149
- };
150
- }
151
- const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
152
- if (!prompt)
153
- return null;
154
- const branchStatus = await statusForBranch(home, gcBranch);
155
- const previousCache = await readHookCache(home, sessionId);
156
- const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
157
- if (previousCache?.last_prompt_signature === promptSignature &&
158
- recentDuplicate(previousCache.last_prompt_at, now)) {
159
- return null;
160
- }
161
- const cache = nextCacheState(previousCache, {
162
- sessionId,
163
- gcBranch,
164
- currentRepo,
165
- repoScopeStatus,
166
- });
167
- cache.last_prompt_signature = promptSignature;
168
- cache.last_prompt_at = now.toISOString();
169
- let additionalContext;
170
- if (repoScopeStatus === 'excluded') {
171
- cache.branch_excluded = true;
172
- additionalContext = buildExcludedContext({
200
+ const cache = nextCacheState(previousCache, {
201
+ sessionId,
173
202
  gcBranch,
174
203
  currentRepo,
175
- cached: previousCache?.branch_excluded === true,
204
+ repoScopeStatus,
176
205
  });
177
- }
178
- else if (branchStatus.doc_count === 0) {
179
- const wasCached = cache.branch_empty;
180
- cache.branch_empty = true;
181
- additionalContext = buildEmptyBranchContext({ gcBranch, currentRepo, cached: wasCached });
182
- }
183
- else if (repoScopeStatus === 'unmapped' && currentRepo && cache.unmapped_shown_repos.includes(currentRepo)) {
184
- // Already showed context for this unmapped repo this session — skip silently.
185
- cache.updated_at = now.toISOString();
186
- await writeHookCache(home, cache);
187
- return null;
188
- }
189
- else {
190
- // For unmapped repos: use prompt only (no repo prefix) to avoid bias; apply higher score threshold.
191
- const isUnmapped = repoScopeStatus === 'unmapped';
192
- const query = !isUnmapped && currentRepo ? `${currentRepo} ${prompt}` : prompt;
193
- const signature = hashQuery(query);
194
- if (!isUnmapped && cache.no_match_signatures.includes(signature)) {
195
- additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: true });
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;
196
227
  }
197
228
  else {
198
- const result = await resolveContext({ home, branch: gcBranch, query });
199
- const effectiveMatches = result.matches;
200
- if (effectiveMatches.length === 0) {
201
- if (isUnmapped) {
202
- // Unmapped + no strong match: skip silently, no noise.
203
- cache.updated_at = now.toISOString();
204
- await writeHookCache(home, cache);
205
- return null;
206
- }
207
- cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
208
- 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 });
209
235
  }
210
236
  else {
211
- if (!isUnmapped) {
212
- cache.no_match_signatures = cache.no_match_signatures.filter((entry) => entry !== signature);
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 });
213
248
  }
214
- else if (currentRepo) {
215
- // Mark this unmapped repo as shown so we don't repeat next prompt.
216
- cache.unmapped_shown_repos = [...new Set([...cache.unmapped_shown_repos, currentRepo])];
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 });
217
258
  }
218
- additionalContext = buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches: effectiveMatches });
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;
219
263
  }
220
264
  }
221
- if (cache.prompt_count > 0 && cache.prompt_count % SELF_REVIEW_INTERVAL === 0) {
222
- additionalContext += buildSelfReviewAppend(cache.prompt_count);
223
- cache.prompt_count = 0;
224
- }
265
+ cache.updated_at = now.toISOString();
266
+ await writeHookCache(home, cache);
267
+ return { hookSpecificOutput: { hookEventName: event, additionalContext } };
268
+ }
269
+ finally {
270
+ await releaseLock();
225
271
  }
226
- cache.updated_at = now.toISOString();
227
- await writeHookCache(home, cache);
228
- return { hookSpecificOutput: { hookEventName: event, additionalContext } };
229
272
  }
@@ -36,7 +36,7 @@ export function onboardingProtocolLines() {
36
36
  'Treat `index.md` as concept-first: show the keywords a user or AI would search for, not just broad document titles.',
37
37
  'Generate index entries automatically from primary concept names, aliases, repository nicknames, and workflow labels when those are clear.',
38
38
  'Split glossary docs when a concept is likely to be searched directly, needs more than a short definition, or carries workflow/constraint details; keep only low-value leftover terms in a shared glossary.',
39
- 'Keep each doc summary-first so the top section gives the gist before deeper details.',
39
+ 'The `## Summary` section of every doc must be actionable, not descriptive — write the actual patterns, commands, or constraints a developer needs, not a sentence about what the doc covers. Bad: "이 문서는 updateCollection 패턴을 설명합니다." Good: "updateCollection: { ...dto } spread 필수. return plainToInstance(Res, result satisfies Res). 새 필드 추가 = DTO → 서비스 → 컨트롤러 순서." The summary is injected into the AI context before every task — if it reads like a table of contents entry, it is useless.',
40
40
  'Treat `index.md` as a human-readable dictionary-style table of contents grouped by category headings and `label -> path` entries.',
41
41
  ];
42
42
  }
@@ -301,11 +301,19 @@ export async function scaffoldHostIntegration({ host, targetDir, force = false,
301
301
  await unmergeGcTreeHooksJson(oldHooksPath);
302
302
  }
303
303
  }
304
- await mergeGcTreeHooksJson({
305
- filePath: hookPath,
306
- target: isCodex ? 'codex' : 'claude-code',
307
- });
308
- written.push(hookPath);
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handsupmin/gc-tree",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "description": "Global Context Tree, a lightweight branch-aware global context orchestrator for AI coding tools",
5
5
  "type": "module",
6
6
  "private": false,
@@ -39,7 +39,7 @@ Use this when a user wants to create global context for a product, company, or w
39
39
  - when the inspected evidence already covers a repository well enough, ask only for missing deltas instead of re-asking role, paths, and workflow from scratch
40
40
  - only ask open-ended repository questions when the needed detail cannot be recovered responsibly from local evidence
41
41
  - ask about glossary terms and default verification commands before you finish
42
- - write compact source docs with a required `## Summary` section near the top
42
+ - write compact source docs with a required `## Summary` section near the top; the `## Summary` must be actionable — write the actual patterns, commands, or constraints a developer needs, not a sentence about what the doc covers (bad: "이 문서는 X를 설명합니다"; good: "updateX: { ...dto } spread 필수. return plainToInstance(Res, result satisfies Res). 새 필드 추가 = DTO → 서비스 → 컨트롤러 순서."); the summary is injected into AI context before every task — if it reads like a table of contents entry, it is useless
43
43
  - prefer an encyclopedia-style context set with many small docs instead of a few broad docs
44
44
  - prefer category directories like `docs/role/`, `docs/repos/`, `docs/domain/`, `docs/workflows/`, `docs/conventions/`, and `docs/infra/`
45
45
  - prefer one concept, one repository, one workflow, or one convention per file when possible