@handsupmin/gc-tree 0.4.2 → 0.4.4

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
@@ -11,6 +11,7 @@ import { scaffoldHostIntegration } from './scaffold.js';
11
11
  import { requirePreferredProvider, writeSettings, readSettings } from './settings.js';
12
12
  import { checkoutBranch, initHome, listBranches, readHead, resetBranchContext, statusForBranch, ensureBranchExists, isBranchContextEmpty } from './store.js';
13
13
  import { updateBranchContext } from './update.js';
14
+ import { uninstallGcTree } from './uninstall.js';
14
15
  import { verifyOnboarding } from './verify-onboarding.js';
15
16
  function readArg(flag) {
16
17
  const index = process.argv.indexOf(flag);
@@ -31,6 +32,7 @@ function usage() {
31
32
  gctree onboard [--home DIR] [--branch NAME] [--provider <codex|claude-code>] [--target DIR] [--no-launch]
32
33
  gctree verify-onboarding [--home DIR] [--branch NAME]
33
34
  gctree reset-gc-branch [--home DIR] [--branch NAME] --yes
35
+ gctree uninstall [--home DIR] [--target DIR] [--host <codex|claude-code|both>] [--keep-home] --yes
34
36
  gctree resolve --query TEXT [--home DIR] [--branch NAME] [--cwd DIR]
35
37
  gctree show-doc --id ID [--home DIR] [--branch NAME]
36
38
  gctree related --id ID [--home DIR] [--branch NAME]
@@ -276,6 +278,21 @@ async function main() {
276
278
  console.log(JSON.stringify(result, null, 2));
277
279
  return;
278
280
  }
281
+ case 'uninstall': {
282
+ if (!hasFlag('--yes')) {
283
+ throw new Error('uninstall is destructive. Re-run with --yes to confirm.');
284
+ }
285
+ const host = normalizeProviderMode(readArg('--host')) || 'both';
286
+ const targetDir = readArg('--target') || process.cwd();
287
+ const result = await uninstallGcTree({
288
+ home,
289
+ targetDir,
290
+ host,
291
+ keepHome: hasFlag('--keep-home'),
292
+ });
293
+ console.log(JSON.stringify(result, null, 2));
294
+ return;
295
+ }
279
296
  case 'resolve': {
280
297
  const query = readArg('--query');
281
298
  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.4",
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,
@@ -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.