@handsupmin/gc-tree 0.4.2 → 0.4.5

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/README.es.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
+ ```text
6
+ __
7
+ /\ \__
8
+ __ ___\ \ ,_\ _ __ __ __
9
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
10
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
11
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
12
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
13
+ /\____/
14
+ \_/__/
15
+ ```
16
+
5
17
  <img src="./logo.png" alt="logo de gc-tree" width="260" />
6
18
 
7
19
  ### Contexto global, más allá del proyecto.
package/README.ja.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
+ ```text
6
+ __
7
+ /\ \__
8
+ __ ___\ \ ,_\ _ __ __ __
9
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
10
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
11
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
12
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
13
+ /\____/
14
+ \_/__/
15
+ ```
16
+
5
17
  <img src="./logo.png" alt="gc-tree logo" width="260" />
6
18
 
7
19
  ### プロジェクトの外まで届くグローバルコンテキスト。
package/README.ko.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
+ ```text
6
+ __
7
+ /\ \__
8
+ __ ___\ \ ,_\ _ __ __ __
9
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
10
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
11
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
12
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
13
+ /\____/
14
+ \_/__/
15
+ ```
16
+
5
17
  <img src="./logo.png" alt="gc-tree 로고" width="260" />
6
18
 
7
19
  ### 프로젝트 밖까지 이어지는 글로벌 컨텍스트.
package/README.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
+ ```text
6
+ __
7
+ /\ \__
8
+ __ ___\ \ ,_\ _ __ __ __
9
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
10
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
11
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
12
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
13
+ /\____/
14
+ \_/__/
15
+ ```
16
+
5
17
  <img src="./logo.png" alt="gc-tree logo" width="260" />
6
18
 
7
19
  ### Global context, beyond the project.
package/README.zh.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
+ ```text
6
+ __
7
+ /\ \__
8
+ __ ___\ \ ,_\ _ __ __ __
9
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
10
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
11
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
12
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
13
+ /\____/
14
+ \_/__/
15
+ ```
16
+
5
17
  <img src="./logo.png" alt="gc-tree logo" width="260" />
6
18
 
7
19
  ### 不止于项目内部的全局上下文。
package/ascii-logo.txt ADDED
@@ -0,0 +1,9 @@
1
+ __
2
+ /\ \__
3
+ __ ___\ \ ,_\ _ __ __ __
4
+ /'_ `\ /'___\ \ \/ /\`'__\/'__`\ /'__`\
5
+ /\ \L\ \/\ \__/\ \ \_\ \ \//\ __//\ __/
6
+ \ \____ \ \____\\ \__\\ \_\\ \____\ \____\
7
+ \/___L\ \/____/ \/__/ \/_/ \/____/\/____/
8
+ /\____/
9
+ \_/__/
package/ascii-tree.txt ADDED
@@ -0,0 +1,44 @@
1
+
2
+ QzczXO
3
+ Yvczzvvz
4
+ Juvw cvc
5
+ JzXY YvvXXuvc zXXY
6
+ CvvccvcU zuvX zvvcvvcY
7
+ cvU QvcO \uu: vX: JvX
8
+ Yvv0:zvc \uu: vvY ZcvC
9
+ XvvvvcU UcvvvX YX \uu: 0vc cvvccL XvvvvcY
10
+ Uvc: Ycvv vvv bcu \uv: Ovc vvU Qvc cvC
11
+ UvcO Ocvv cvvX Ycu \uuO Zvc XvvJ Zvca OvvC
12
+ UvvvvvQ vuu LcvvuUcu \uuO ZvczvvvcO cuY QvvvvvJ
13
+ \zcccX Quv cvv Ycvvu \uuO XvvvcX zvY XvU zzzzX
14
+ zvucucvcL Quv cvv Ycu \uuzuvvz zvU XvU cvvcucvcY
15
+ cvU zvc JvvU zvvvQ acu \uvvvY JvvvY CvvU JvY cvX
16
+ UvvzczvvJ 0cvvcXOYvvvzJ cvvYX\uvc Yzvvvz YcvvvC zvvzczvcJ
17
+ LvvvU XcvvzXXcvvcJ Uvvvvvvc JcvvvXXzvvcXO zvvuO
18
+ Cvc OXzzzzzXuvvz zUvvvc OXvvuXXzzzzXO OccO
19
+ Yvvvvvz zvvuU zvvvvvc vvvd nvvc OvvvY wvvvvvY OUvvvX 0vvvvvY
20
+ cvX QvvzXYUcvvvvJ CvvY Mvva uvvc ZvvO cvz: OzvvvvXUUXXvza Ycvz
21
+ cvY acvuuuuuuuvvC UvvcX Mcvh uvvc mvva OXvvz Xvuuuuuuuuvz Xvz
22
+ CcvvvvvY zzvvvvvvvvvcL zvvk nvvc Ovva OvvvcYvvvvvcO Uvvcvvz\
23
+ zzuvvXz Yuvvcv avvZ nvvv mvvY zzvvuU Jzuvvzz
24
+ UvvYYUvv0obhhhhoh*hoXvvvzUvv0 nvvc ZvvJXvvvzhao##h##hhhQvvUJUcvU
25
+ Uvz Yvvvvvvvvvvvvvvvvvvvvv0 uvvv muvvvvvvvvvvvvvvvvvvvvY cvY
26
+ JcvcCcvcb zXvvvvvvvvvvvvvvJO bvvcLcvcC
27
+ CzvzL YuvvvvvvvvvQ CXvYL
28
+ Jcvvvvvm
29
+ CvvvvvvZ
30
+ zvvvvvvY
31
+ cvvvvvvJ
32
+ nuvvvvuc
33
+ uuvvvvuc
34
+ vvvvvvuu
35
+ Jvvvvvvu
36
+ zvvvvvvv
37
+ JvvvvvvU
38
+ vvvvvvvU
39
+ Ovvvvvvvc
40
+ JuvvvvvvuL
41
+ zvvvvvvvvvvY
42
+ OXzvvvvzvvvzcvvvvzXO
43
+ YYcvvvvvvXCzvvvvX:XvvvvvvcXQ
44
+ YYYYYYYYUXO QUUUYUUYC: OcYUYUYYYYz
@@ -0,0 +1,25 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ async function readAsciiAsset(fileName) {
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const candidates = [
7
+ join(here, '..', fileName),
8
+ join(here, '..', '..', fileName),
9
+ ];
10
+ for (const candidate of candidates) {
11
+ try {
12
+ return await readFile(candidate, 'utf8');
13
+ }
14
+ catch {
15
+ continue;
16
+ }
17
+ }
18
+ throw new Error(`ascii asset not found: ${fileName}`);
19
+ }
20
+ export async function readAsciiLogo() {
21
+ return readAsciiAsset('ascii-logo.txt');
22
+ }
23
+ export async function readAsciiTree() {
24
+ return readAsciiAsset('ascii-tree.txt');
25
+ }
package/dist/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
- import { stdin } from 'node:process';
3
+ import { stdin, stderr, stdout } from 'node:process';
4
+ import { readAsciiLogo, readAsciiTree } from './ascii.js';
4
5
  import { dispatchGcTreeHook } from './hook.js';
5
6
  import { onboardBranch } from './onboard.js';
6
7
  import { buildProviderLaunchPlan, maybeLaunchProvider, promptLanguageSelection, promptLaunchProviderSelection, promptProviderSelection, } from './provider.js';
@@ -11,6 +12,7 @@ import { scaffoldHostIntegration } from './scaffold.js';
11
12
  import { requirePreferredProvider, writeSettings, readSettings } from './settings.js';
12
13
  import { checkoutBranch, initHome, listBranches, readHead, resetBranchContext, statusForBranch, ensureBranchExists, isBranchContextEmpty } from './store.js';
13
14
  import { updateBranchContext } from './update.js';
15
+ import { uninstallGcTree } from './uninstall.js';
14
16
  import { verifyOnboarding } from './verify-onboarding.js';
15
17
  function readArg(flag) {
16
18
  const index = process.argv.indexOf(flag);
@@ -19,8 +21,23 @@ function readArg(flag) {
19
21
  function hasFlag(flag) {
20
22
  return process.argv.includes(flag);
21
23
  }
24
+ async function readPackageVersion() {
25
+ const candidates = ['../package.json', '../../package.json'];
26
+ for (const relative of candidates) {
27
+ try {
28
+ const raw = await readFile(new URL(relative, import.meta.url), 'utf8');
29
+ return JSON.parse(raw).version || '0.0.0';
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ }
35
+ return '0.0.0';
36
+ }
22
37
  function usage() {
23
38
  console.error(`Usage:
39
+ gctree --version
40
+ gctree plant
24
41
  gctree init [--home DIR] [--provider <claude-code|codex|both>] [--language TEXT] [--target DIR] [--no-launch]
25
42
  gctree checkout <branch> [--home DIR]
26
43
  gctree checkout -b <branch> [--home DIR]
@@ -31,6 +48,7 @@ function usage() {
31
48
  gctree onboard [--home DIR] [--branch NAME] [--provider <codex|claude-code>] [--target DIR] [--no-launch]
32
49
  gctree verify-onboarding [--home DIR] [--branch NAME]
33
50
  gctree reset-gc-branch [--home DIR] [--branch NAME] --yes
51
+ gctree uninstall [--home DIR] [--target DIR] [--host <codex|claude-code|both>] [--keep-home] --yes
34
52
  gctree resolve --query TEXT [--home DIR] [--branch NAME] [--cwd DIR]
35
53
  gctree show-doc --id ID [--home DIR] [--branch NAME]
36
54
  gctree related --id ID [--home DIR] [--branch NAME]
@@ -121,13 +139,28 @@ async function launchGuidedFlow({ provider, providerMode, preferredLanguage, tar
121
139
  return maybeLaunchProvider(plan);
122
140
  }
123
141
  async function main() {
142
+ if (hasFlag('--version') || hasFlag('-v')) {
143
+ stdout.write(`${await readPackageVersion()}\n`);
144
+ return;
145
+ }
124
146
  const rawCommand = process.argv[2];
125
147
  if (!rawCommand || rawCommand === '--help' || rawCommand === 'help')
126
148
  usage();
127
149
  const command = rawCommand === 'update-gc' || rawCommand === 'ugc' ? 'update-global-context' : rawCommand;
128
150
  const home = resolveHome(readArg('--home'));
129
151
  switch (command) {
152
+ case 'plant': {
153
+ const asciiTree = await readAsciiTree();
154
+ stdout.write(asciiTree);
155
+ if (!asciiTree.endsWith('\n'))
156
+ stdout.write('\n');
157
+ return;
158
+ }
130
159
  case 'init': {
160
+ const asciiLogo = await readAsciiLogo();
161
+ stderr.write(asciiLogo);
162
+ if (!asciiLogo.endsWith('\n'))
163
+ stderr.write('\n');
131
164
  const providerMode = await maybePromptProviderMode(readArg('--provider'));
132
165
  const provider = await resolveLaunchProvider(providerMode);
133
166
  const preferredLanguage = await maybePromptLanguage(readArg('--language'));
@@ -276,6 +309,21 @@ async function main() {
276
309
  console.log(JSON.stringify(result, null, 2));
277
310
  return;
278
311
  }
312
+ case 'uninstall': {
313
+ if (!hasFlag('--yes')) {
314
+ throw new Error('uninstall is destructive. Re-run with --yes to confirm.');
315
+ }
316
+ const host = normalizeProviderMode(readArg('--host')) || 'both';
317
+ const targetDir = readArg('--target') || process.cwd();
318
+ const result = await uninstallGcTree({
319
+ home,
320
+ targetDir,
321
+ host,
322
+ keepHome: hasFlag('--keep-home'),
323
+ });
324
+ console.log(JSON.stringify(result, null, 2));
325
+ return;
326
+ }
279
327
  case 'resolve': {
280
328
  const query = readArg('--query');
281
329
  if (!query)
@@ -0,0 +1,167 @@
1
+ import { access, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { mkdir } from 'node:fs/promises';
5
+ const MARKERS = {
6
+ codex: {
7
+ start: '<!-- gctree:codex:start -->',
8
+ end: '<!-- gctree:codex:end -->',
9
+ },
10
+ claude: {
11
+ start: '<!-- gctree:claude:start -->',
12
+ end: '<!-- gctree:claude:end -->',
13
+ },
14
+ };
15
+ export async function pathExists(target) {
16
+ try {
17
+ await access(target, constants.F_OK);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function injectManagedBlock(existing, content, start, end) {
25
+ const block = `${start}\n${content.trimEnd()}\n${end}`;
26
+ const startIndex = existing.indexOf(start);
27
+ const endIndex = existing.indexOf(end);
28
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
29
+ return `${existing.slice(0, startIndex).trimEnd()}\n\n${block}\n${existing.slice(endIndex + end.length).trimStart()}`.trimEnd() + '\n';
30
+ }
31
+ const trimmed = existing.trimEnd();
32
+ return `${trimmed ? `${trimmed}\n\n` : ''}${block}\n`;
33
+ }
34
+ function removeManagedBlock(existing, start, end) {
35
+ const startIndex = existing.indexOf(start);
36
+ const endIndex = existing.indexOf(end);
37
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex)
38
+ return existing;
39
+ const before = existing.slice(0, startIndex).trimEnd();
40
+ const after = existing.slice(endIndex + end.length).trimStart();
41
+ const merged = [before, after].filter(Boolean).join('\n\n');
42
+ return merged ? `${merged}\n` : '';
43
+ }
44
+ export async function upsertManagedMarkdownBlock({ filePath, content, marker, }) {
45
+ const { start, end } = MARKERS[marker];
46
+ const existing = (await pathExists(filePath)) ? await readFile(filePath, 'utf8') : '';
47
+ const next = injectManagedBlock(existing, content, start, end);
48
+ await mkdir(dirname(filePath), { recursive: true });
49
+ await writeFile(filePath, next, 'utf8');
50
+ return existing ? 'updated' : 'created';
51
+ }
52
+ export async function removeManagedMarkdownBlock({ filePath, marker, }) {
53
+ if (!(await pathExists(filePath)))
54
+ return 'missing';
55
+ const { start, end } = MARKERS[marker];
56
+ const existing = await readFile(filePath, 'utf8');
57
+ const next = removeManagedBlock(existing, start, end);
58
+ if (!next.trim()) {
59
+ await rm(filePath, { force: true });
60
+ return 'deleted';
61
+ }
62
+ if (next === existing)
63
+ return 'missing';
64
+ await writeFile(filePath, next, 'utf8');
65
+ return 'removed';
66
+ }
67
+ function buildHookEntry(event) {
68
+ return {
69
+ type: 'command',
70
+ command: `gctree __hook --event ${event}`,
71
+ metadata: {
72
+ owner: 'gctree',
73
+ },
74
+ ...(event === 'UserPromptSubmit' ? { timeout: 10 } : {}),
75
+ };
76
+ }
77
+ function ensureObject(value) {
78
+ return value && typeof value === 'object' ? value : {};
79
+ }
80
+ function isGcTreeHook(entry, event) {
81
+ const command = String(entry.command || '');
82
+ const owner = ensureObject(entry.metadata).owner;
83
+ return owner === 'gctree' || command === `gctree __hook --event ${event}`;
84
+ }
85
+ function mergeHookJson(raw, target) {
86
+ const parsed = raw ? ensureObject(JSON.parse(raw)) : {};
87
+ const hooks = ensureObject(parsed.hooks);
88
+ const sessionEvent = 'SessionStart';
89
+ const promptEvent = 'UserPromptSubmit';
90
+ const ensureEventArray = (event) => {
91
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
92
+ hooks[event] = current;
93
+ return current;
94
+ };
95
+ const upsertGroup = (event, matcher) => {
96
+ const groups = ensureEventArray(event);
97
+ let group = groups.find((candidate) => {
98
+ const hooksArr = Array.isArray(candidate?.hooks) ? candidate.hooks : [];
99
+ return hooksArr.some((entry) => isGcTreeHook(ensureObject(entry), event));
100
+ });
101
+ if (!group) {
102
+ group = matcher ? { matcher, hooks: [] } : { hooks: [] };
103
+ groups.push(group);
104
+ }
105
+ if (!Array.isArray(group.hooks))
106
+ group.hooks = [];
107
+ group.hooks = group.hooks.filter((entry) => !isGcTreeHook(ensureObject(entry), event));
108
+ group.hooks.push(buildHookEntry(event));
109
+ };
110
+ upsertGroup(sessionEvent, target === 'codex' ? 'startup|resume' : '*');
111
+ upsertGroup(promptEvent, target === 'codex' ? undefined : '*');
112
+ return `${JSON.stringify({ hooks }, null, 2)}\n`;
113
+ }
114
+ function unmergeHookJson(raw, events) {
115
+ const parsed = ensureObject(JSON.parse(raw));
116
+ const hooks = ensureObject(parsed.hooks);
117
+ for (const event of events) {
118
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
119
+ const nextGroups = groups
120
+ .map((group) => {
121
+ const hooksArr = Array.isArray(group.hooks) ? group.hooks : [];
122
+ const nextHooks = hooksArr.filter((entry) => !isGcTreeHook(ensureObject(entry), event));
123
+ return nextHooks.length > 0 ? { ...group, hooks: nextHooks } : null;
124
+ })
125
+ .filter(Boolean);
126
+ if (nextGroups.length > 0)
127
+ hooks[event] = nextGroups;
128
+ else
129
+ delete hooks[event];
130
+ }
131
+ if (Object.keys(hooks).length === 0)
132
+ return '';
133
+ return `${JSON.stringify({ hooks }, null, 2)}\n`;
134
+ }
135
+ export async function mergeGcTreeHooksJson({ filePath, target, }) {
136
+ const existing = (await pathExists(filePath)) ? await readFile(filePath, 'utf8') : null;
137
+ const next = mergeHookJson(existing, target);
138
+ await mkdir(dirname(filePath), { recursive: true });
139
+ await writeFile(filePath, next, 'utf8');
140
+ return existing ? 'updated' : 'created';
141
+ }
142
+ export async function unmergeGcTreeHooksJson(filePath) {
143
+ if (!(await pathExists(filePath)))
144
+ return 'missing';
145
+ const existing = await readFile(filePath, 'utf8');
146
+ const next = unmergeHookJson(existing, ['SessionStart', 'UserPromptSubmit']);
147
+ if (!next.trim()) {
148
+ await rm(filePath, { force: true });
149
+ return 'deleted';
150
+ }
151
+ if (next === existing)
152
+ return 'missing';
153
+ await writeFile(filePath, next, 'utf8');
154
+ return 'removed';
155
+ }
156
+ export function gctreeManagedMarkdownTargets(targetDir) {
157
+ return {
158
+ codex: join(targetDir, 'AGENTS.md'),
159
+ claude: join(targetDir, 'CLAUDE.md'),
160
+ };
161
+ }
162
+ export function gctreeHookJsonTargets(targetDir) {
163
+ return {
164
+ codex: join(targetDir, '.codex', 'hooks.json'),
165
+ claude: join(targetDir, '.claude', 'hooks', 'hooks.json'),
166
+ };
167
+ }
@@ -45,35 +45,93 @@ export function renderIndexMarkdown(input) {
45
45
  lines.push('- No source docs yet.', '');
46
46
  }
47
47
  else {
48
- for (const doc of input.docs.sort((a, b) => a.title.localeCompare(b.title))) {
49
- lines.push(`- ${doc.title} -> ${doc.path}`);
48
+ const categoryOrder = ['role', 'repos', 'domain', 'workflows', 'conventions', 'infra', 'verification', 'general'];
49
+ const grouped = new Map();
50
+ for (const doc of input.docs) {
51
+ const category = normalizeCategory(doc.category || deriveCategoryFromPath(doc.path));
52
+ if (!grouped.has(category))
53
+ grouped.set(category, []);
54
+ grouped.get(category).push(doc);
55
+ }
56
+ const categories = [...grouped.keys()].sort((a, b) => {
57
+ const ai = categoryOrder.indexOf(a);
58
+ const bi = categoryOrder.indexOf(b);
59
+ if (ai === -1 && bi === -1)
60
+ return a.localeCompare(b);
61
+ if (ai === -1)
62
+ return 1;
63
+ if (bi === -1)
64
+ return -1;
65
+ return ai - bi;
66
+ });
67
+ for (const category of categories) {
68
+ lines.push(`## ${displayCategory(category)}`, '');
69
+ for (const doc of grouped.get(category).sort((a, b) => (a.label || a.title).localeCompare(b.label || b.title))) {
70
+ lines.push(`- ${doc.label || doc.title} -> ${doc.path}`);
71
+ }
72
+ lines.push('');
50
73
  }
51
- lines.push('');
52
74
  }
53
75
  return lines.join('\n');
54
76
  }
55
77
  export function docIdFromPath(docPath) {
56
- const fileName = String(docPath || '')
57
- .trim()
58
- .split('/')
59
- .pop() || '';
60
- return fileName.replace(/\.md$/i, '') || 'doc';
78
+ const normalized = String(docPath || '').trim().replace(/^docs\//, '');
79
+ return normalized.replace(/\.md$/i, '') || 'doc';
80
+ }
81
+ export function normalizeCategory(value) {
82
+ return slugify(value || 'general');
83
+ }
84
+ export function deriveCategoryFromPath(docPath) {
85
+ const normalized = String(docPath || '').trim().replace(/^docs\//, '');
86
+ const parts = normalized.split('/').filter(Boolean);
87
+ if (parts.length > 1)
88
+ return normalizeCategory(parts[0]);
89
+ return 'general';
90
+ }
91
+ export function displayCategory(category) {
92
+ const normalized = normalizeCategory(category);
93
+ const predefined = {
94
+ role: 'Role',
95
+ repos: 'Repos',
96
+ domain: 'Domain',
97
+ workflows: 'Workflows',
98
+ conventions: 'Conventions',
99
+ infra: 'Infra',
100
+ verification: 'Verification',
101
+ general: 'General',
102
+ };
103
+ return predefined[normalized] || normalized.split('-').map((part) => part[0]?.toUpperCase() + part.slice(1)).join(' ');
61
104
  }
62
105
  export function parseIndexEntries(indexContent) {
106
+ let currentCategory = 'general';
63
107
  return String(indexContent || '')
64
108
  .split(/\r?\n/)
65
109
  .map((line) => line.trim())
66
- .filter((line) => /^- .+ -> .+$/.test(line) && !line.startsWith('- gc-branch:') && !line.startsWith('- summary:'))
67
- .map((line) => {
110
+ .flatMap((line) => {
111
+ if (!line)
112
+ return [];
113
+ const heading = line.match(/^##\s+(.+)$/);
114
+ if (heading) {
115
+ currentCategory = normalizeCategory(heading[1] || 'general');
116
+ return [];
117
+ }
118
+ if (!/^-\s+.+\s+->\s+.+$/.test(line) || line.startsWith('- gc-branch:') || line.startsWith('- summary:')) {
119
+ return [];
120
+ }
68
121
  const body = line.slice(2);
69
- const [title, path] = body.split('->').map((part) => part.trim());
70
- return { id: docIdFromPath(path), title, path };
122
+ const [label, path] = body.split('->').map((part) => part.trim());
123
+ const category = deriveCategoryFromPath(path) || currentCategory;
124
+ return [{ id: docIdFromPath(path), title: label, label, category, path }];
71
125
  });
72
126
  }
73
127
  export function extractSummary(markdown) {
74
128
  const match = String(markdown || '').match(/## Summary\s+([\s\S]*?)(?:\n## |$)/);
75
129
  return match?.[1]?.trim() || '';
76
130
  }
131
+ export function extractTitle(markdown) {
132
+ const match = String(markdown || '').match(/^#\s+(.+)$/m);
133
+ return match?.[1]?.trim() || '';
134
+ }
77
135
  export function extractExcerpt(markdown, query) {
78
136
  const content = String(markdown || '').replace(/\s+/g, ' ').trim();
79
137
  if (!content)
@@ -1,26 +1,24 @@
1
- import { mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { renderDocMarkdown } from './markdown.js';
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { renderDocMarkdown, slugify } from './markdown.js';
4
4
  import { branchDocsDir, DEFAULT_BRANCH } from './paths.js';
5
5
  import { ensureBranchExists, updateBranchMeta, writeIndexFromDocs } from './store.js';
6
+ function docRelativePath(doc) {
7
+ if (doc.slug?.includes('/'))
8
+ return `${doc.slug.replace(/\.md$/i, '')}.md`;
9
+ const fileBase = slugify(doc.slug || doc.indexLabel || doc.title);
10
+ const category = doc.category ? slugify(doc.category) : '';
11
+ return category ? `${category}/${fileBase}.md` : `${fileBase}.md`;
12
+ }
6
13
  export async function onboardBranch({ home, input, branch, }) {
7
14
  const targetBranch = branch || input.branch || DEFAULT_BRANCH;
8
15
  await ensureBranchExists(home, targetBranch);
16
+ await rm(branchDocsDir(home, targetBranch), { recursive: true, force: true });
9
17
  await mkdir(branchDocsDir(home, targetBranch), { recursive: true });
10
- const existing = await readdir(branchDocsDir(home, targetBranch)).catch(() => []);
11
- await Promise.all(existing
12
- .filter((file) => file.endsWith('.md'))
13
- .map((file) => unlink(join(branchDocsDir(home, targetBranch), file))));
14
18
  const written = [];
15
19
  for (const doc of input.docs) {
16
- const relativePath = `${doc.slug ? doc.slug : doc.title}`;
17
- const fileName = `${relativePath
18
- .trim()
19
- .toLowerCase()
20
- .replace(/[^a-z0-9]+/g, '-')
21
- .replace(/-+/g, '-')
22
- .replace(/^-|-$/g, '') || 'doc'}.md`;
23
- const fullPath = join(branchDocsDir(home, targetBranch), fileName);
20
+ const fullPath = join(branchDocsDir(home, targetBranch), docRelativePath(doc));
21
+ await mkdir(dirname(fullPath), { recursive: true });
24
22
  await writeFile(fullPath, renderDocMarkdown(doc), 'utf8');
25
23
  written.push(fullPath);
26
24
  }
@@ -23,7 +23,11 @@ export function onboardingProtocolLines() {
23
23
  'Ask whether there are additional repositories for the current work type before moving on.',
24
24
  'After repository coverage, ask for company/domain glossary terms and acronyms that should become durable context.',
25
25
  'Then ask which verification commands should be treated as defaults for this kind of work.',
26
- 'Synthesize the interview into durable docs such as a role/profile summary, work types, repository roles, glossary, and verification defaults.',
26
+ 'Synthesize the interview into an encyclopedia-style context set with many small docs instead of a few broad docs.',
27
+ 'Prefer category directories such as `docs/role/`, `docs/repos/`, `docs/domain/`, `docs/workflows/`, `docs/conventions/`, and `docs/infra/` whenever that split fits the material.',
28
+ 'Prefer one concept, one repository, one workflow, or one convention per file when possible.',
29
+ 'Keep each doc summary-first so the top section gives the gist before deeper details.',
30
+ 'Treat `index.md` as a human-readable dictionary-style table of contents grouped by category headings and `label -> path` entries.',
27
31
  ];
28
32
  }
29
33
  export function onboardingCompletionLines() {
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { docIdFromPath, extractExcerpt, extractSummary, parseIndexEntries } from './markdown.js';
3
+ import { deriveCategoryFromPath, docIdFromPath, extractExcerpt, extractSummary, extractTitle, parseIndexEntries } from './markdown.js';
4
4
  import { branchDir, branchIndexPath } from './paths.js';
5
5
  // Common English stop words that are too short or generic to be meaningful query signals.
6
6
  const STOP_WORDS = new Set([
@@ -49,7 +49,9 @@ async function readBranchDocs(home, branch) {
49
49
  const raw = await readFile(fullPath, 'utf8');
50
50
  return {
51
51
  id: entry.id || docIdFromPath(entry.path),
52
- title: entry.title,
52
+ title: extractTitle(raw) || entry.title,
53
+ label: entry.label,
54
+ category: entry.category || deriveCategoryFromPath(entry.path),
53
55
  path: entry.path,
54
56
  summary: extractSummary(raw),
55
57
  content: raw,
@@ -69,6 +71,8 @@ export async function resolveContext({ home, branch, query, }) {
69
71
  continue;
70
72
  matches.push({
71
73
  id: entry.id,
74
+ label: entry.label,
75
+ category: entry.category,
72
76
  title: entry.title,
73
77
  path: entry.path,
74
78
  score,
@@ -105,6 +109,8 @@ export async function findRelatedDocs({ home, branch, id, limit = 5, }) {
105
109
  return score > 0
106
110
  ? {
107
111
  id: doc.id,
112
+ label: doc.label,
113
+ category: doc.category,
108
114
  title: doc.title,
109
115
  path: doc.path,
110
116
  score,
@@ -1,5 +1,6 @@
1
1
  import { access, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
+ import { gctreeHookJsonTargets, gctreeManagedMarkdownTargets, mergeGcTreeHooksJson, upsertManagedMarkdownBlock, } from './integration-files.js';
3
4
  import { onboardingCompletionLines, onboardingProtocolLines } from './onboarding-protocol.js';
4
5
  function renderCodexAgentsSnippet() {
5
6
  return [
@@ -204,7 +205,7 @@ function renderClaudeUpdateCommand() {
204
205
  '',
205
206
  ].join('\n');
206
207
  }
207
- function scaffoldFiles(host) {
208
+ export function scaffoldFiles(host) {
208
209
  if (host === 'codex') {
209
210
  return [
210
211
  { path: 'AGENTS.md', content: renderCodexAgentsSnippet() },
@@ -228,10 +229,31 @@ export async function scaffoldHostIntegration({ host, targetDir, force = false,
228
229
  const files = scaffoldFiles(host);
229
230
  const written = [];
230
231
  const skippedExisting = [];
232
+ const markdownTargets = gctreeManagedMarkdownTargets(targetDir);
233
+ const hookTargets = gctreeHookJsonTargets(targetDir);
234
+ const isCodex = host === 'codex';
235
+ const managedMarkdownPath = isCodex ? markdownTargets.codex : markdownTargets.claude;
236
+ const managedMarkdownContent = isCodex ? renderCodexAgentsSnippet() : renderClaudeSnippet();
237
+ await upsertManagedMarkdownBlock({
238
+ filePath: managedMarkdownPath,
239
+ content: managedMarkdownContent,
240
+ marker: isCodex ? 'codex' : 'claude',
241
+ });
242
+ written.push(managedMarkdownPath);
243
+ const hookPath = isCodex ? hookTargets.codex : hookTargets.claude;
244
+ await mergeGcTreeHooksJson({
245
+ filePath: hookPath,
246
+ target: isCodex ? 'codex' : 'claude-code',
247
+ });
248
+ written.push(hookPath);
231
249
  const targets = files.map((file) => ({
232
250
  ...file,
233
251
  fullPath: join(targetDir, file.path),
234
- }));
252
+ })).filter((target) => {
253
+ if (isCodex)
254
+ return target.path !== 'AGENTS.md' && target.path !== '.codex/hooks.json';
255
+ return target.path !== 'CLAUDE.md' && target.path !== '.claude/hooks/hooks.json';
256
+ });
235
257
  for (const target of targets) {
236
258
  let exists = false;
237
259
  try {
package/dist/src/store.js CHANGED
@@ -3,6 +3,21 @@ import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { branchDir, branchDocsDir, branchIndexPath, branchMetaPath, branchesRoot, DEFAULT_BRANCH, headPath, INDEX_WARNING_CHARS, } from './paths.js';
5
5
  import { renderIndexMarkdown } from './markdown.js';
6
+ async function listDocRelativePaths(dir, prefix = '') {
7
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
8
+ const files = [];
9
+ for (const entry of entries) {
10
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
11
+ const fullPath = join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ files.push(...(await listDocRelativePaths(fullPath, relative)));
14
+ }
15
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
16
+ files.push(relative);
17
+ }
18
+ }
19
+ return files.sort();
20
+ }
6
21
  export async function ensureHome(home) {
7
22
  await mkdir(branchesRoot(home), { recursive: true });
8
23
  }
@@ -93,11 +108,14 @@ export async function checkoutBranch(home, branch, create = false) {
93
108
  export async function writeIndexFromDocs(home, branch) {
94
109
  const docsDir = branchDocsDir(home, branch);
95
110
  await mkdir(docsDir, { recursive: true });
96
- const files = (await readdir(docsDir)).filter((file) => file.endsWith('.md')).sort();
111
+ const files = await listDocRelativePaths(docsDir);
97
112
  const docs = await Promise.all(files.map(async (file) => {
98
113
  const raw = await readFile(join(docsDir, file), 'utf8');
99
114
  const title = raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || file.replace(/\.md$/i, '').replace(/-/g, ' ');
100
- return { title, path: `docs/${file}` };
115
+ const parts = file.replace(/\.md$/i, '').split('/').filter(Boolean);
116
+ const category = parts.length > 1 ? parts[0] : 'general';
117
+ const label = parts.length > 1 ? parts[parts.length - 1] : title;
118
+ return { title, label, category, path: `docs/${file}` };
101
119
  }));
102
120
  const meta = await readBranchMeta(home, branch);
103
121
  const index = renderIndexMarkdown({ branch, branchSummary: meta.summary, docs });
@@ -105,7 +123,7 @@ export async function writeIndexFromDocs(home, branch) {
105
123
  return { index_path: branchIndexPath(home, branch), doc_count: docs.length };
106
124
  }
107
125
  export async function isBranchContextEmpty(home, branch) {
108
- const docs = (await readdir(branchDocsDir(home, branch)).catch(() => [])).filter((file) => file.endsWith('.md'));
126
+ const docs = await listDocRelativePaths(branchDocsDir(home, branch));
109
127
  return docs.length === 0;
110
128
  }
111
129
  export async function resetBranchContext(home, branch) {
@@ -122,7 +140,7 @@ export async function resetBranchContext(home, branch) {
122
140
  }
123
141
  export async function statusForBranch(home, branch) {
124
142
  const indexRaw = await readFile(branchIndexPath(home, branch), 'utf8');
125
- const docs = (await readdir(branchDocsDir(home, branch)).catch(() => [])).filter((file) => file.endsWith('.md'));
143
+ const docs = await listDocRelativePaths(branchDocsDir(home, branch));
126
144
  const warnings = [];
127
145
  if (indexRaw.length > INDEX_WARNING_CHARS) {
128
146
  warnings.push(`index.md is ${indexRaw.length} chars; keep it closer to an index than a knowledge dump.`);
@@ -0,0 +1,82 @@
1
+ import { readdir, rm } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { gctreeHookJsonTargets, gctreeManagedMarkdownTargets, pathExists, removeManagedMarkdownBlock, unmergeGcTreeHooksJson, } from './integration-files.js';
4
+ import { scaffoldFiles } from './scaffold.js';
5
+ async function pathHasEntries(path) {
6
+ const entries = await readdir(path).catch(() => []);
7
+ return entries.length > 0;
8
+ }
9
+ async function pruneEmptyParents(targetPath, stopDir) {
10
+ let current = dirname(targetPath);
11
+ while (current.startsWith(stopDir) && current !== stopDir) {
12
+ if (await pathHasEntries(current))
13
+ return;
14
+ await rm(current, { recursive: true, force: true });
15
+ current = dirname(current);
16
+ }
17
+ }
18
+ function hostsFor(mode) {
19
+ return mode === 'both' ? ['claude-code', 'codex'] : [mode];
20
+ }
21
+ export async function uninstallGcTree({ home, targetDir, host = 'both', keepHome = false, }) {
22
+ const removed = [];
23
+ const markdownTargets = gctreeManagedMarkdownTargets(targetDir);
24
+ const hookTargets = gctreeHookJsonTargets(targetDir);
25
+ for (const entry of hostsFor(host)) {
26
+ if (entry === 'codex') {
27
+ const markdownStatus = await removeManagedMarkdownBlock({
28
+ filePath: markdownTargets.codex,
29
+ marker: 'codex',
30
+ });
31
+ if (markdownStatus !== 'missing') {
32
+ removed.push(markdownTargets.codex);
33
+ await pruneEmptyParents(markdownTargets.codex, targetDir);
34
+ }
35
+ const hookStatus = await unmergeGcTreeHooksJson(hookTargets.codex);
36
+ if (hookStatus !== 'missing') {
37
+ removed.push(hookTargets.codex);
38
+ await pruneEmptyParents(hookTargets.codex, targetDir);
39
+ }
40
+ }
41
+ else {
42
+ const markdownStatus = await removeManagedMarkdownBlock({
43
+ filePath: markdownTargets.claude,
44
+ marker: 'claude',
45
+ });
46
+ if (markdownStatus !== 'missing') {
47
+ removed.push(markdownTargets.claude);
48
+ await pruneEmptyParents(markdownTargets.claude, targetDir);
49
+ }
50
+ const hookStatus = await unmergeGcTreeHooksJson(hookTargets.claude);
51
+ if (hookStatus !== 'missing') {
52
+ removed.push(hookTargets.claude);
53
+ await pruneEmptyParents(hookTargets.claude, targetDir);
54
+ }
55
+ }
56
+ const extraTargets = scaffoldFiles(entry)
57
+ .map((file) => join(targetDir, file.path))
58
+ .filter((path) => entry === 'codex'
59
+ ? path !== markdownTargets.codex && path !== hookTargets.codex
60
+ : path !== markdownTargets.claude && path !== hookTargets.claude);
61
+ for (const target of [...new Set(extraTargets)]) {
62
+ if (!(await pathExists(target)))
63
+ continue;
64
+ await rm(target, { recursive: true, force: true });
65
+ removed.push(target);
66
+ await pruneEmptyParents(target, targetDir);
67
+ }
68
+ }
69
+ let homeRemoved = false;
70
+ if (!keepHome && (await pathExists(home))) {
71
+ await rm(home, { recursive: true, force: true });
72
+ homeRemoved = true;
73
+ removed.push(home);
74
+ }
75
+ return {
76
+ target_dir: targetDir,
77
+ home,
78
+ host,
79
+ home_removed: homeRemoved,
80
+ removed,
81
+ };
82
+ }
@@ -1,16 +1,23 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { renderDocMarkdown, slugify } from './markdown.js';
4
4
  import { branchDocsDir, DEFAULT_BRANCH } from './paths.js';
5
5
  import { ensureBranchExists, updateBranchMeta, writeIndexFromDocs } from './store.js';
6
+ function docRelativePath(doc) {
7
+ if (doc.slug?.includes('/'))
8
+ return `${doc.slug.replace(/\.md$/i, '')}.md`;
9
+ const fileBase = slugify(doc.slug || doc.indexLabel || doc.title);
10
+ const category = doc.category ? slugify(doc.category) : '';
11
+ return category ? `${category}/${fileBase}.md` : `${fileBase}.md`;
12
+ }
6
13
  export async function updateBranchContext({ home, input, branch, }) {
7
14
  const targetBranch = branch || input.branch || DEFAULT_BRANCH;
8
15
  await ensureBranchExists(home, targetBranch);
9
16
  await mkdir(branchDocsDir(home, targetBranch), { recursive: true });
10
17
  const written = [];
11
18
  for (const doc of input.docs) {
12
- const fileName = `${slugify(doc.slug || doc.title)}.md`;
13
- const fullPath = join(branchDocsDir(home, targetBranch), fileName);
19
+ const fullPath = join(branchDocsDir(home, targetBranch), docRelativePath(doc));
20
+ await mkdir(dirname(fullPath), { recursive: true });
14
21
  await writeFile(fullPath, renderDocMarkdown(doc), 'utf8');
15
22
  written.push(fullPath);
16
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handsupmin/gc-tree",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
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,
@@ -9,6 +9,8 @@
9
9
  "skills",
10
10
  "scaffolds",
11
11
  "docs",
12
+ "ascii-logo.txt",
13
+ "ascii-tree.txt",
12
14
  "README.md",
13
15
  "LICENSE"
14
16
  ],
@@ -32,7 +32,10 @@ Use this when a user wants to create global context for a product, company, or w
32
32
  - ask for multiple work types if needed, then multiple repos inside each work type if needed
33
33
  - ask about glossary terms and default verification commands before you finish
34
34
  - write compact source docs with a required `## Summary` section near the top
35
- - keep `index.md` as an index only
35
+ - prefer an encyclopedia-style context set with many small docs instead of a few broad docs
36
+ - prefer category directories like `docs/role/`, `docs/repos/`, `docs/domain/`, `docs/workflows/`, `docs/conventions/`, and `docs/infra/`
37
+ - prefer one concept, one repository, one workflow, or one convention per file when possible
38
+ - keep `index.md` as a human-readable dictionary-style TOC grouped by category headings and `label -> path` entries
36
39
  - use onboarding only for an empty gc-branch
37
40
 
38
41
  ## Procedure
@@ -63,13 +66,15 @@ Use this when a user wants to create global context for a product, company, or w
63
66
  17. After the user's first answer, proactively inspect relevant local repos, docs, paths, and workflows whenever the connection is strong enough to test your current frame.
64
67
  18. Ask for company/domain glossary terms and acronyms that should become durable context.
65
68
  19. Ask which verification commands should be treated as defaults for this gc-branch.
66
- 20. Launch the guided onboarding flow with `gctree onboard [--branch <name>]`.
67
- 21. Before you claim onboarding is complete, run `gctree verify-onboarding --branch <current-gc-branch>` and inspect the real gc-tree files.
68
- 22. Do not claim onboarding is complete unless verification returns `status: "complete"`.
69
- 23. After the onboarding docs are written, explicitly list which durable docs were saved.
70
- 24. Summarize what you now understand from the saved docs instead of ending at the filenames alone.
71
- 25. Ask whether that final summary matches the user's reality, and capture any corrections before you wrap up.
72
- 26. Ask whether anything else should be saved while the context is still fresh.
73
- 27. Do not finish onboarding while material related repos, workflows, or domain terms remain uninspected when recoverable local evidence is still available.
74
- 28. Only after the related repos, workflows, glossary, and default verification commands are either captured or explicitly unavailable should you wrap up, then remind the user that future changes belong in `gctree update-global-context`.
75
- 29. Keep the current gc-branch explicit while gathering context.
69
+ 20. Structure the durable docs as a small encyclopedia: split by category directory, keep one concept/repo/workflow/convention per file when possible, and keep a short `## Summary` at the top of each doc.
70
+ 21. Render `index.md` as a category-grouped dictionary-style table of contents with `label -> path` entries.
71
+ 22. Launch the guided onboarding flow with `gctree onboard [--branch <name>]`.
72
+ 23. Before you claim onboarding is complete, run `gctree verify-onboarding --branch <current-gc-branch>` and inspect the real gc-tree files.
73
+ 24. Do not claim onboarding is complete unless verification returns `status: "complete"`.
74
+ 25. After the onboarding docs are written, explicitly list which durable docs were saved.
75
+ 26. Summarize what you now understand from the saved docs instead of ending at the filenames alone.
76
+ 27. Ask whether that final summary matches the user's reality, and capture any corrections before you wrap up.
77
+ 28. Ask whether anything else should be saved while the context is still fresh.
78
+ 29. Do not finish onboarding while material related repos, workflows, or domain terms remain uninspected when recoverable local evidence is still available.
79
+ 30. Only after the related repos, workflows, glossary, and default verification commands are either captured or explicitly unavailable should you wrap up, then remind the user that future changes belong in `gctree update-global-context`.
80
+ 31. Keep the current gc-branch explicit while gathering context.