@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 +17 -0
- package/dist/src/integration-files.js +167 -0
- package/dist/src/markdown.js +70 -12
- package/dist/src/onboard.js +13 -15
- package/dist/src/onboarding-protocol.js +5 -1
- package/dist/src/resolve.js +8 -2
- package/dist/src/scaffold.js +24 -2
- package/dist/src/store.js +22 -4
- package/dist/src/uninstall.js +82 -0
- package/dist/src/update.js +10 -3
- package/package.json +1 -1
- package/skills/onboard/SKILL.md +16 -11
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
|
+
}
|
package/dist/src/markdown.js
CHANGED
|
@@ -45,35 +45,93 @@ export function renderIndexMarkdown(input) {
|
|
|
45
45
|
lines.push('- No source docs yet.', '');
|
|
46
46
|
}
|
|
47
47
|
else {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
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
|
-
.
|
|
67
|
-
|
|
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 [
|
|
70
|
-
|
|
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)
|
package/dist/src/onboard.js
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
import { mkdir,
|
|
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
|
|
17
|
-
|
|
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
|
|
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() {
|
package/dist/src/resolve.js
CHANGED
|
@@ -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,
|
package/dist/src/scaffold.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/dist/src/update.js
CHANGED
|
@@ -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
|
|
13
|
-
|
|
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
package/skills/onboard/SKILL.md
CHANGED
|
@@ -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
|
-
-
|
|
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.
|
|
67
|
-
21.
|
|
68
|
-
22.
|
|
69
|
-
23.
|
|
70
|
-
24.
|
|
71
|
-
25.
|
|
72
|
-
26.
|
|
73
|
-
27.
|
|
74
|
-
28.
|
|
75
|
-
29.
|
|
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.
|