great-cto 2.77.0 → 2.77.2
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/board/.claude-plugin/plugin.json +256 -0
- package/board/packages/board/lib/alerts.mjs +413 -0
- package/board/packages/board/lib/beads.mjs +241 -0
- package/board/packages/board/lib/config.mjs +49 -0
- package/board/packages/board/lib/data-readers.mjs +340 -0
- package/board/packages/board/lib/fleet.mjs +363 -0
- package/board/packages/board/lib/metrics.mjs +282 -0
- package/board/packages/board/lib/notifications.mjs +50 -0
- package/board/packages/board/lib/projects.mjs +307 -0
- package/board/packages/board/lib/routes.mjs +1036 -0
- package/board/packages/board/lib/share.mjs +143 -0
- package/board/packages/board/lib/sse.mjs +20 -0
- package/board/packages/board/lib/state.mjs +31 -0
- package/board/packages/board/lib/util.mjs +36 -0
- package/board/packages/board/lib/verdicts.mjs +168 -0
- package/board/packages/board/lib/watchers.mjs +116 -0
- package/board/packages/board/mcp-server.mjs +310 -0
- package/board/packages/board/public/assets/apple-touch-icon.png +0 -0
- package/board/packages/board/public/assets/favicon-16.png +0 -0
- package/board/packages/board/public/assets/favicon-32.png +0 -0
- package/board/packages/board/public/assets/favicon.ico +0 -0
- package/board/packages/board/public/assets/favicon.svg +8 -0
- package/board/packages/board/public/assets/fonts/PROVENANCE.md +21 -0
- package/board/packages/board/public/assets/fonts/fonts.css +26 -0
- package/board/packages/board/public/assets/fonts/geist-mono-variable.woff2 +0 -0
- package/board/packages/board/public/assets/fonts/geist-variable.woff2 +0 -0
- package/board/packages/board/public/assets/vendor/PROVENANCE.md +21 -0
- package/board/packages/board/public/assets/vendor/marked.min.js +6 -0
- package/board/packages/board/public/assets/vendor/purify.min.js +3 -0
- package/board/packages/board/public/index.html +5464 -0
- package/board/packages/board/public/share.html +1194 -0
- package/board/packages/board/public/sw.js +79 -0
- package/board/packages/board/push-adapter.mjs +222 -0
- package/board/packages/board/server.mjs +133 -0
- package/board/packages/cli/dist/archetypes.js +1461 -0
- package/board/scripts/lib/change-tier.mjs +119 -0
- package/board/scripts/lib/gate-plan.mjs +119 -0
- package/board/scripts/lib/judge-model.mjs +42 -0
- package/dist/board-path.js +47 -0
- package/dist/main.js +3 -31
- package/package.json +3 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { GREAT_CTO_DIR, NOTIF_HISTORY_FILE } from './config.mjs';
|
|
4
|
+
import { notifHistory, MAX_NOTIF_HISTORY } from './state.mjs';
|
|
5
|
+
import { eventSurface } from './util.mjs';
|
|
6
|
+
import { broadcast } from './sse.mjs';
|
|
7
|
+
|
|
8
|
+
function loadNotifHistory() {
|
|
9
|
+
try {
|
|
10
|
+
if (fs.existsSync(NOTIF_HISTORY_FILE)) {
|
|
11
|
+
const parsed = JSON.parse(fs.readFileSync(NOTIF_HISTORY_FILE, 'utf8'));
|
|
12
|
+
if (Array.isArray(parsed)) { notifHistory.length = 0; notifHistory.push(...parsed); }
|
|
13
|
+
}
|
|
14
|
+
} catch { /* start fresh on corrupt file */ }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveNotifHistory() {
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
|
|
20
|
+
fs.writeFileSync(NOTIF_HISTORY_FILE, JSON.stringify(notifHistory, null, 2));
|
|
21
|
+
} catch { /* best-effort */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Record a notification, broadcast via SSE, and persist.
|
|
26
|
+
* Called alongside fireEmailAlert / firePushAlert at every trigger point.
|
|
27
|
+
*/
|
|
28
|
+
function addNotification(event, payload) {
|
|
29
|
+
const notif = {
|
|
30
|
+
id: crypto.randomUUID(),
|
|
31
|
+
event,
|
|
32
|
+
surface: eventSurface(event),
|
|
33
|
+
title: payload.title,
|
|
34
|
+
body: payload.body,
|
|
35
|
+
level: payload.level || 'info',
|
|
36
|
+
project: payload.project || '',
|
|
37
|
+
ts: new Date().toISOString(),
|
|
38
|
+
read: false,
|
|
39
|
+
};
|
|
40
|
+
notifHistory.unshift(notif);
|
|
41
|
+
if (notifHistory.length > MAX_NOTIF_HISTORY) notifHistory.length = MAX_NOTIF_HISTORY;
|
|
42
|
+
broadcast('notification', notif);
|
|
43
|
+
saveNotifHistory();
|
|
44
|
+
return notif;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load history at server start
|
|
48
|
+
loadNotifHistory();
|
|
49
|
+
|
|
50
|
+
export { loadNotifHistory, saveNotifHistory, addNotification };
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { planGates } from '../../../scripts/lib/gate-plan.mjs';
|
|
6
|
+
import { GREAT_CTO_DIR, PROJECTS_FILE } from './config.mjs';
|
|
7
|
+
|
|
8
|
+
// ── Project registry ───────────────────────────────────────────────────────────
|
|
9
|
+
function readProjectsRegistry() {
|
|
10
|
+
try { if (fs.existsSync(PROJECTS_FILE)) return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); }
|
|
11
|
+
catch {}
|
|
12
|
+
return { projects: [] };
|
|
13
|
+
}
|
|
14
|
+
// Pick the best entry among several sharing a slug: prefer one whose path
|
|
15
|
+
// still exists on disk; among several existing (or several missing), prefer
|
|
16
|
+
// the most recently added_at. Never returns a non-existent path when an
|
|
17
|
+
// existing match is available (great_cto-7xfc).
|
|
18
|
+
function pickBestBySlug(candidates) {
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const p of candidates) {
|
|
21
|
+
if (!best) { best = p; continue; }
|
|
22
|
+
const pExists = existsSafe(p.path);
|
|
23
|
+
const bestExists = existsSafe(best.path);
|
|
24
|
+
if (pExists && !bestExists) { best = p; continue; }
|
|
25
|
+
if (!pExists && bestExists) continue;
|
|
26
|
+
// Both exist or both missing — prefer most recent added_at.
|
|
27
|
+
if (String(p.added_at || '') > String(best.added_at || '')) best = p;
|
|
28
|
+
}
|
|
29
|
+
return best;
|
|
30
|
+
}
|
|
31
|
+
function existsSafe(p) {
|
|
32
|
+
try { return !!p && fs.existsSync(p); } catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
// Dedupe registry entries by slug before writing, so the registry self-heals
|
|
35
|
+
// on every write regardless of how the duplicate got in (great_cto-7xfc).
|
|
36
|
+
function dedupeProjects(projects) {
|
|
37
|
+
const bySlug = new Map();
|
|
38
|
+
for (const p of projects) {
|
|
39
|
+
if (!p || !p.slug) continue;
|
|
40
|
+
const group = bySlug.get(p.slug) || [];
|
|
41
|
+
group.push(p);
|
|
42
|
+
bySlug.set(p.slug, group);
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const group of bySlug.values()) out.push(pickBestBySlug(group));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function writeProjectsRegistry(reg) {
|
|
49
|
+
fs.mkdirSync(GREAT_CTO_DIR, { recursive: true });
|
|
50
|
+
const deduped = { ...reg, projects: dedupeProjects(reg.projects || []) };
|
|
51
|
+
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(deduped, null, 2));
|
|
52
|
+
}
|
|
53
|
+
// Map legacy / informal archetype names to canonical archetype slugs.
|
|
54
|
+
// This lets the board correctly badge old PROJECT.md files (mobile, saas-platform,
|
|
55
|
+
// ai-agent, etc.) without forcing users to rewrite them.
|
|
56
|
+
const ARCHETYPE_ALIASES = {
|
|
57
|
+
'mobile': 'mobile-app',
|
|
58
|
+
'mobile-android': 'mobile-app',
|
|
59
|
+
'mobile-ios': 'mobile-app',
|
|
60
|
+
'saas-platform': 'enterprise-saas',
|
|
61
|
+
'saas': 'enterprise-saas',
|
|
62
|
+
'ai-agent': 'agent-product',
|
|
63
|
+
'agent': 'agent-product',
|
|
64
|
+
'rag-system': 'ai-system',
|
|
65
|
+
'llm-app': 'ai-system',
|
|
66
|
+
'trading-system': 'fintech',
|
|
67
|
+
'financial-platform': 'fintech',
|
|
68
|
+
'neobroker': 'fintech',
|
|
69
|
+
'neobank': 'fintech',
|
|
70
|
+
'ml-platform': 'mlops',
|
|
71
|
+
'ml-training': 'mlops',
|
|
72
|
+
'ml-serving': 'mlops',
|
|
73
|
+
'web-fullstack': 'web-service',
|
|
74
|
+
'web-app': 'web-service',
|
|
75
|
+
'api': 'web-service',
|
|
76
|
+
'backend': 'web-service',
|
|
77
|
+
'cli': 'cli-tool',
|
|
78
|
+
'sdk': 'library',
|
|
79
|
+
'npm-library': 'library',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function normalizeArchetype(raw) {
|
|
83
|
+
if (!raw) return '';
|
|
84
|
+
// Strip backticks, quotes, surrounding whitespace
|
|
85
|
+
const clean = raw.replace(/[`'"]/g, '').trim().toLowerCase();
|
|
86
|
+
// Take first token if comma/plus-separated (e.g. "trading-system + financial-platform")
|
|
87
|
+
const first = clean.split(/[,+]/)[0].trim();
|
|
88
|
+
return ARCHETYPE_ALIASES[first] || first;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractArchetype(text) {
|
|
92
|
+
// 1. Canonical: `archetype: foo`
|
|
93
|
+
let m = text.match(/^archetype:\s*(.+)$/m);
|
|
94
|
+
if (m) return normalizeArchetype(m[1]);
|
|
95
|
+
// 2. Legacy yaml-like: `primary: foo`
|
|
96
|
+
m = text.match(/^primary:\s*(.+)$/m);
|
|
97
|
+
if (m) return normalizeArchetype(m[1]);
|
|
98
|
+
// 3. Markdown list (bullet): `- Primary: \`foo\`` or `- primary: foo + bar`
|
|
99
|
+
m = text.match(/^[-*]\s*Primary:\s*(.+)$/im);
|
|
100
|
+
if (m) return normalizeArchetype(m[1]);
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readProjectMd(dir) {
|
|
105
|
+
const p = path.join(dir, '.great_cto', 'PROJECT.md');
|
|
106
|
+
if (!fs.existsSync(p)) return null;
|
|
107
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
108
|
+
const get = k => (text.match(new RegExp(`^${k}:\\s*(.+)$`, 'm')) || [])[1]?.trim() || '';
|
|
109
|
+
return {
|
|
110
|
+
slug: get('project') || get('name') || path.basename(dir),
|
|
111
|
+
archetype: extractArchetype(text) || 'unknown',
|
|
112
|
+
description: get('description') || '',
|
|
113
|
+
path: dir,
|
|
114
|
+
added_at: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// change_tier for a project's working-tree diff (gate-tiering, ADR-003/004): which
|
|
118
|
+
// human gates open for the current change + which judge model runs. Pure planGates()
|
|
119
|
+
// over the project's archetype/size + `git diff --name-only`.
|
|
120
|
+
function getChangeTier(dir) {
|
|
121
|
+
try {
|
|
122
|
+
const meta = readProjectMd(dir);
|
|
123
|
+
if (!meta) return { tier: null, error: 'no PROJECT.md' };
|
|
124
|
+
const pm = fs.readFileSync(path.join(dir, '.great_cto', 'PROJECT.md'), 'utf8');
|
|
125
|
+
const size = (pm.match(/^project_size:\s*(\S+)/m) || [])[1] || 'medium';
|
|
126
|
+
const out = spawnSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir, encoding: 'utf8', timeout: 5000 });
|
|
127
|
+
const changedFiles = String(out.stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
|
|
128
|
+
return planGates({ archetype: meta.archetype, size, changedFiles });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { tier: 'T2', error: e.message }; // fail-safe: unknown → full gates
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function autoRegisterProject(dir) {
|
|
134
|
+
const meta = readProjectMd(dir);
|
|
135
|
+
if (!meta) return null;
|
|
136
|
+
const reg = readProjectsRegistry();
|
|
137
|
+
const existingByPath = reg.projects.find(p => p.path === meta.path);
|
|
138
|
+
if (existingByPath) return meta; // already registered at this path, nothing to do
|
|
139
|
+
const existingBySlug = reg.projects.find(p => p.slug === meta.slug);
|
|
140
|
+
if (existingBySlug) {
|
|
141
|
+
// Repo moved: update the existing entry's path in place instead of
|
|
142
|
+
// inserting a second entry for the same slug (great_cto-7xfc).
|
|
143
|
+
existingBySlug.path = meta.path;
|
|
144
|
+
existingBySlug.archetype = meta.archetype;
|
|
145
|
+
existingBySlug.description = meta.description;
|
|
146
|
+
existingBySlug.added_at = meta.added_at;
|
|
147
|
+
} else {
|
|
148
|
+
reg.projects.push(meta);
|
|
149
|
+
}
|
|
150
|
+
writeProjectsRegistry(reg);
|
|
151
|
+
return meta;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Discover great_cto projects across common dev folders + Claude Code's known
|
|
155
|
+
// project list. Runs at server startup AND every /api/projects request, so any
|
|
156
|
+
// project that ran /audit or /start (which writes .great_cto/PROJECT.md) gets
|
|
157
|
+
// auto-registered without the user having to do anything.
|
|
158
|
+
// Fully async — never blocks the event loop.
|
|
159
|
+
async function discoverProjects() {
|
|
160
|
+
const fsAsync = fs.promises;
|
|
161
|
+
const HOME = os.homedir();
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const found = [];
|
|
164
|
+
|
|
165
|
+
async function scanDir(dir, depth) {
|
|
166
|
+
if (depth < 0 || seen.has(dir)) return;
|
|
167
|
+
seen.add(dir);
|
|
168
|
+
try {
|
|
169
|
+
// Check the dir itself first
|
|
170
|
+
try {
|
|
171
|
+
await fsAsync.access(path.join(dir, '.great_cto', 'PROJECT.md'));
|
|
172
|
+
found.push(dir);
|
|
173
|
+
return; // don't descend into a registered project
|
|
174
|
+
} catch {}
|
|
175
|
+
if (depth === 0) return;
|
|
176
|
+
// Scan children (skip dotfiles + heavyweight dirs)
|
|
177
|
+
const entries = await fsAsync.readdir(dir, { withFileTypes: true });
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (!e.isDirectory()) continue;
|
|
180
|
+
const name = e.name;
|
|
181
|
+
if (name.startsWith('.') || name === 'node_modules' || name === 'dist' ||
|
|
182
|
+
name === 'build' || name === 'target' || name === 'venv' || name === '__pycache__') continue;
|
|
183
|
+
await scanDir(path.join(dir, name), depth - 1);
|
|
184
|
+
}
|
|
185
|
+
} catch {} // permission denied — skip silently
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 1) Common dev folders — top-level scan, 1-level deep
|
|
189
|
+
const roots = [
|
|
190
|
+
path.join(HOME, 'work'),
|
|
191
|
+
path.join(HOME, 'dev'),
|
|
192
|
+
path.join(HOME, 'development'),
|
|
193
|
+
path.join(HOME, 'code'),
|
|
194
|
+
path.join(HOME, 'projects'),
|
|
195
|
+
path.join(HOME, 'src'),
|
|
196
|
+
path.join(HOME, 'Documents', 'projects'),
|
|
197
|
+
HOME,
|
|
198
|
+
];
|
|
199
|
+
for (const root of roots) {
|
|
200
|
+
try { await fsAsync.access(root); await scanDir(root, 1); } catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2) Claude Code's known project list (~/.claude/projects/<encoded-path>/)
|
|
204
|
+
try {
|
|
205
|
+
const ccProj = path.join(HOME, '.claude', 'projects');
|
|
206
|
+
await fsAsync.access(ccProj);
|
|
207
|
+
const entries = await fsAsync.readdir(ccProj);
|
|
208
|
+
for (const dir of entries) {
|
|
209
|
+
// Claude encodes paths as -Users-foo-projects-bar — decode to /Users/foo/projects/bar
|
|
210
|
+
const decoded = '/' + dir.replace(/^-+/, '').replace(/-/g, '/');
|
|
211
|
+
try {
|
|
212
|
+
await fsAsync.access(path.join(decoded, '.great_cto', 'PROJECT.md'));
|
|
213
|
+
found.push(decoded);
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
|
|
218
|
+
// Auto-register everything found
|
|
219
|
+
for (const dir of found) autoRegisterProject(dir);
|
|
220
|
+
return found.length;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function listProjects() {
|
|
224
|
+
// Auto-register cwd if it has PROJECT.md (cheap)
|
|
225
|
+
autoRegisterProject(process.cwd());
|
|
226
|
+
const reg = readProjectsRegistry();
|
|
227
|
+
// Filter out projects whose paths no longer exist
|
|
228
|
+
reg.projects = reg.projects.filter(p => fs.existsSync(p.path));
|
|
229
|
+
// Re-read metadata in case archetype/description changed
|
|
230
|
+
// Enrich with last_activity (mtime of .beads/interactions.jsonl) so the UI
|
|
231
|
+
// can sort projects by recent activity instead of slug-alpha.
|
|
232
|
+
return reg.projects.map(p => {
|
|
233
|
+
const fresh = readProjectMd(p.path);
|
|
234
|
+
let lastActivity = null;
|
|
235
|
+
try {
|
|
236
|
+
const interactionsFile = path.join(p.path, '.beads', 'interactions.jsonl');
|
|
237
|
+
if (fs.existsSync(interactionsFile)) {
|
|
238
|
+
lastActivity = fs.statSync(interactionsFile).mtime.toISOString();
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
return { ...(fresh ? { ...p, ...fresh } : p), last_activity: lastActivity };
|
|
242
|
+
}).sort((a, b) => {
|
|
243
|
+
// Recent activity first; projects with no activity sink to the bottom (alphabetic)
|
|
244
|
+
const ax = a.last_activity || '';
|
|
245
|
+
const bx = b.last_activity || '';
|
|
246
|
+
if (ax && bx) return bx.localeCompare(ax);
|
|
247
|
+
if (ax) return -1;
|
|
248
|
+
if (bx) return 1;
|
|
249
|
+
return (a.slug || '').localeCompare(b.slug || '');
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function resolveProjectCwd(slugOrPath) {
|
|
253
|
+
if (!slugOrPath) return process.cwd();
|
|
254
|
+
// If absolute path or starts with ~, use directly
|
|
255
|
+
if (slugOrPath.startsWith('/')) return slugOrPath;
|
|
256
|
+
if (slugOrPath.startsWith('~')) return slugOrPath.replace(/^~/, os.homedir());
|
|
257
|
+
// Else look up in registry by slug — among duplicate slugs (great_cto-7xfc),
|
|
258
|
+
// prefer the entry whose path exists on disk; among several existing (or
|
|
259
|
+
// several missing), prefer the most recently added_at.
|
|
260
|
+
const reg = readProjectsRegistry();
|
|
261
|
+
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
262
|
+
const found = pickBestBySlug(matches);
|
|
263
|
+
return found ? found.path : process.cwd();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Same as resolveProjectCwd but returns { cwd, resolved, fallback? } so
|
|
268
|
+
* callers know whether resolution was authoritative or fell back to the
|
|
269
|
+
* server's working directory. Used by HTTP handlers to set an explicit
|
|
270
|
+
* `X-Project-Fallback` response header (BH-5 fix, 2026-05-15).
|
|
271
|
+
*
|
|
272
|
+
* resolved values:
|
|
273
|
+
* 'cwd' — no project param passed; using server cwd as documented
|
|
274
|
+
* 'path' — absolute / tilde path passed and used directly
|
|
275
|
+
* 'slug' — slug found in registry
|
|
276
|
+
* 'fallback' — slug requested but NOT in registry; using cwd as fallback.
|
|
277
|
+
* Caller should warn the user (header + log).
|
|
278
|
+
*/
|
|
279
|
+
function resolveProjectInfo(slugOrPath) {
|
|
280
|
+
if (!slugOrPath) return { cwd: process.cwd(), resolved: 'cwd' };
|
|
281
|
+
if (slugOrPath.startsWith('/')) return { cwd: slugOrPath, resolved: 'path' };
|
|
282
|
+
if (slugOrPath.startsWith('~')) {
|
|
283
|
+
return { cwd: slugOrPath.replace(/^~/, os.homedir()), resolved: 'path' };
|
|
284
|
+
}
|
|
285
|
+
const reg = readProjectsRegistry();
|
|
286
|
+
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
287
|
+
const found = pickBestBySlug(matches);
|
|
288
|
+
if (found) return { cwd: found.path, resolved: 'slug' };
|
|
289
|
+
return { cwd: process.cwd(), resolved: 'fallback', requested: slugOrPath };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export {
|
|
293
|
+
readProjectsRegistry,
|
|
294
|
+
writeProjectsRegistry,
|
|
295
|
+
pickBestBySlug,
|
|
296
|
+
dedupeProjects,
|
|
297
|
+
ARCHETYPE_ALIASES,
|
|
298
|
+
normalizeArchetype,
|
|
299
|
+
extractArchetype,
|
|
300
|
+
readProjectMd,
|
|
301
|
+
getChangeTier,
|
|
302
|
+
autoRegisterProject,
|
|
303
|
+
discoverProjects,
|
|
304
|
+
listProjects,
|
|
305
|
+
resolveProjectCwd,
|
|
306
|
+
resolveProjectInfo,
|
|
307
|
+
};
|