@anhvupt/tito 0.1.2 → 0.2.0

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.md CHANGED
@@ -3,12 +3,13 @@
3
3
  **Quiet orchestration. Trusted continuity.**
4
4
 
5
5
  Tito is a chat-first engineering coordinator for solo builders. You talk to one
6
- coordinator; Tito explores the repository, chooses the right working mode,
7
- turns larger work into reviewable slices, and keeps implementation inside the
8
- scope you approved.
6
+ coordinator. Tito confirms what you want, writes the plan as the spec, and
7
+ sends only the approved slice to a sub-agent.
9
8
 
10
- It is local-first, Git-friendly, and designed to add coordination without
11
- replacing your project documentation, rules, or specialists.
9
+ The spec records the decisions and the conventions behind them, the test cases
10
+ you review before they are written, and the docs that must change. Tito is
11
+ local-first and Git-friendly. It coordinates your documentation, rules, and
12
+ specialists, and it updates the docs named in the plan before a pull request.
12
13
 
13
14
  ## What Tito helps with
14
15
 
@@ -17,9 +18,16 @@ replacing your project documentation, rules, or specialists.
17
18
  - **The right amount of process:** Tito recommends Ask for discovery, Plan for
18
19
  uncertain or critical work, and Agent only for an approved implementation
19
20
  slice.
20
- - **Plans that make decisions:** the planner owns technical choices, records
21
- important trade-offs, and includes signatures, schemas, pseudocode, or
22
- guidance code when that makes implementation clearer.
21
+ - **Plans that make decisions:** Tito confirms the goal and what is out of
22
+ scope, then asks one batch of clarifying questions, lighter on `solo-fast`.
23
+ One obvious reading continues after a one-line confirmation. Tito still asks
24
+ before locking a technical decision or a product-vision change. The plan
25
+ records that decision and includes signatures, schemas, pseudocode, or
26
+ guidance code when that makes implementation clearer. Decisions name the
27
+ convention they follow. Test cases carry a layer tag: `[unit]`,
28
+ `[integration]`, or `[e2e]`. The plan also lists its docs impact.
29
+ - **A light joke, sometimes:** after `Hola, Tito here!`, Tito may add one short
30
+ joke. It does not replace the answer, and it stays out of the CLI.
23
31
  - **Smaller reviews:** work is split into bounded slices with acceptance
24
32
  criteria, tests, forbidden changes, risks, and a stop condition.
25
33
  - **Safer changes:** Tito preserves uncommitted work, refuses silent overwrites,
@@ -52,11 +60,15 @@ mode decides whether it may change anything:
52
60
  | Tech docs writer | — | Update technical documentation |
53
61
  | User docs writer | — | Update user documentation |
54
62
 
55
- Several read-only specialists may work together. Only one mutating mode may be
56
- active. Backend and frontend work is split into sequential slices. After a
57
- module is finished, Tito schedules the tech docs writer and then the user docs
58
- writer, one at a time, before calling that module done. Skip that handoff only
59
- when you explicitly waive it for that module. Each specialist carries triggers,
63
+ Several read-only specialists may work together. Coding sub-agents follow the
64
+ profile cap: `client-careful` 3, `solo-balanced` 6, and `solo-fast` 12. Each
65
+ has its own plan and branch. Tito does not code in the chat. Every change,
66
+ including a small one, goes to a sub-agent, and Tito returns to the user.
67
+ Backend and frontend work can run together inside that cap. Docs named in
68
+ the plan are updated, or you waive them, before Tito offers a pull request.
69
+ After a module is finished, Tito still schedules the tech docs writer and then
70
+ the user docs writer, one at a time, before calling that module done. Skip
71
+ that handoff only when you explicitly waive it for that module. Each specialist carries triggers,
60
72
  knowledge references, handoffs, and a stop condition. The detailed prompt stays
61
73
  lean and loads knowledge only when that mode needs it.
62
74
 
@@ -92,10 +104,22 @@ skills. If `AGENTS.md` already exists, Tito appends its bootstrap and leaves
92
104
  the existing guidance in place. It refuses to overwrite a Tito file that is
93
105
  already there. Open a new Cursor chat, then start with `/tito`.
94
106
 
95
- Tito is installed in each project. It is not a required global command, and
96
- there is no global configuration yet. A future personal default would be
97
- overridden by that project's `tito.yaml`. The safety floor still cannot be
98
- weakened.
107
+ Upgrade an existing project after installing a newer Tito:
108
+
109
+ ```sh
110
+ npx tito upgrade
111
+ npx tito upgrade --confirm
112
+ ```
113
+
114
+ `--confirm` installs the latest `@anhvupt/tito` and replaces Tito-owned
115
+ agents, skills, and the Tito section of `AGENTS.md`. Consumer rules, consumer
116
+ agents, and other project skills stay untouched.
117
+
118
+ Tito is installed in each project. It is not a required global command.
119
+ An opt-in local admin index (`tito admin add|list|remove|refresh`) can register
120
+ repos under `~/.config/tito/admin/` and store recent commit subjects, dates, and
121
+ touched paths — never diffs. A project `tito.yaml` still overrides any personal
122
+ default. The safety floor cannot be weakened.
99
123
 
100
124
  ## What works today
101
125
 
@@ -131,6 +155,11 @@ node dist/cli.js inspect
131
155
  node dist/cli.js inspect --root .
132
156
  node dist/cli.js apply --dry-run --profile solo-balanced
133
157
  node dist/cli.js init --profile solo-balanced
158
+ node dist/cli.js admin add
159
+ node dist/cli.js admin list
160
+ node dist/cli.js admin refresh
161
+ node dist/cli.js admin remove --root /path/to/repo
162
+ node dist/cli.js admin retro --week 2026-W40
134
163
  ```
135
164
 
136
165
  `inspect` reads `tito.yaml` when it exists and checks whether `AGENTS.md` is
@@ -144,6 +173,15 @@ specialist agent files. It writes those files only with `--confirm`, and it
144
173
  refuses when an existing file would be overwritten. In chat, `/tito-init` runs
145
174
  this same command.
146
175
 
176
+ `admin` is opt-in. `add` registers the current repo (or `--root`), `list` prints
177
+ registered paths, `remove` drops one path, and `refresh` writes local branch plus
178
+ the last 50 commit subjects, dates, and file paths (secrets like `.env` skipped;
179
+ no diffs) for chat and CLI to share.
180
+
181
+ `retro` builds that week's report from local git and saves `retros/<week>.json`
182
+ and `retros/<week>.md` under the admin root. Review-fix counts, docs-blank, and
183
+ merge duration say unavailable until GitHub data is added.
184
+
147
185
  ## How work moves
148
186
 
149
187
  The normal careful path is:
@@ -154,6 +192,39 @@ A small, obvious change can move from discovery directly to an explicitly
154
192
  approved slice. Review feedback can return an in-scope slice to implementation.
155
193
  Other lifecycle skips are rejected.
156
194
 
195
+ ## Git flow
196
+
197
+ Tito suggests the branch before checkout, for example `Suggested branch: feat/short-slug from develop`. You accept it or name another base or type. The base is `dev`, `develop`, `main`, or `master`. `dev` and `develop` are interchangeable. `main` and `master` are interchangeable. The type is `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`.
198
+
199
+ `tito.yaml` may set the default base:
200
+
201
+ ```yaml
202
+ schemaVersion: 1
203
+ profile: client-careful
204
+ git:
205
+ defaultBase: develop
206
+ ```
207
+
208
+ `product.screenLanguage` is optional and accepts only `vi` or `en`. A missing product block is valid. A missing `screenLanguage` is valid. Unknown product fields are rejected. `product.tenancy` is optional (`single` or `multi`). `product.surfaces` is an optional list of `{ id }` entries. Missing `tenancy` and missing `surfaces` stay valid.
209
+
210
+ ```yaml
211
+ product:
212
+ screenLanguage: vi
213
+ tenancy: single
214
+ surfaces:
215
+ - id: admin
216
+ ```
217
+
218
+ - Code stays English.
219
+ - In a Vietnamese app (`screenLanguage: vi`), routes and slugs are Vietnamese first. The public path is native Vietnamese, for example `/tien-ich/ca-phe`. Do not invent that Vietnamese by translating an English slug word for word. If the product is bilingual, the English route comes second.
220
+ - An English app (`screenLanguage: en`) keeps English routes and slugs.
221
+ - In a Vietnamese app (`screenLanguage: vi`), every string a person reads is Vietnamese: tables, labels, buttons, headings, and messages. Write native Vietnamese first. Do not invent it by translating English word for word. English may exist as a second field only when the product is bilingual, and it comes after the Vietnamese.
222
+ - An English app (`screenLanguage: en`) stays English on screen.
223
+ - When `screenLanguage` is missing and the screen language is unclear, Tito asks once. One obvious reading continues without a question.
224
+ - Globalized apps store timestamps in UTC and show them in the user's timezone. There is no switch to turn that off.
225
+
226
+ A commit subject is `<type>: <sentence>`. The type is the same token as the branch type: `feat`, `fix`, `hot-fix`, `chores`, `refactor`, or `debug`. Examples: `feat: add commit message rule`, `fix: reject a duplicate surface id`, `chores: record the commit prefix rule`, and `hot-fix: stop a bad release build`. The words after the colon are one finished sentence of at most 70 words. The type is not counted in those 70 words. The body is a separate description. When you review a plan, Tito saves that plan and your edit as separate files under `.tito/feedback/<slug>/`. `review.md` starts with front matter `outcome: accepted | edited | expanded`. Tito indexes only the plan name. The plan body stays in Cursor's plan file. After an approved slice is coded, every review fix goes into one plan named `review/<slug>` on the same branch. Tito asks before opening a pull request only after that plan is coded, or when you accept the code with no changes. The pull request description has four parts within 2 to 50 lines: a one-line problem, what changed, review fixes, and checks for lint, code quality, conventions, tests, build, and docs. Init creates `.github/pull_request_template.md` from Tito's template when it is missing. Upgrade replaces that file with Tito's template. Tito does not approve a pull request. Tito merges only when you call for the merge and the pull request already has an approval. After a pull request is merged, Tito asks before the next slice. Tito switches back to the base branch only when you say so clearly. Tito pushes directly to the base branch only when you clearly instruct that push.
227
+
157
228
  ## Development
158
229
 
159
230
  Requirements:
package/dist/cli.js CHANGED
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
2
3
  import { createInterface } from "node:readline/promises";
3
4
  import { stdin as input, stdout as output } from "node:process";
4
5
  import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
  import { parseArgs } from "node:util";
6
8
  import { InspectionError, formatInspection, inspectRepository, } from "./core/inspect.js";
7
9
  import { PlanError, formatAdoptionPlan, planAdoption } from "./core/plan.js";
8
10
  import { InitError, applyInitialization, formatInitialization, planInitialization, promptForProfile, } from "./core/init.js";
11
+ import { applyUpgrade, formatUpgrade, planUpgrade, titoOwnedFiles, } from "./core/upgrade.js";
12
+ import { AdminError, addRepo, formatContexts, formatRepos, listRepos, refreshRepos, removeRepo, resolveAdminRoot, } from "./core/admin.js";
13
+ import { RetroError, buildRetro, formatRetro, retroJson, saveRetro, } from "./core/retro.js";
9
14
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
15
  const help = `Tito — quiet orchestration for solo builders
11
16
 
@@ -14,6 +19,12 @@ Usage:
14
19
  tito inspect [--root <path>]
15
20
  tito apply --dry-run --profile <id> [--root <path>]
16
21
  tito init [--profile <id>] [--root <path>] [--confirm]
22
+ tito upgrade [--root <path>] [--confirm]
23
+ tito admin add [--root <path>] [--admin-root <path>]
24
+ tito admin list [--admin-root <path>]
25
+ tito admin remove --root <path> [--admin-root <path>]
26
+ tito admin refresh [--admin-root <path>]
27
+ tito admin retro [--week <YYYY-Www>] [--timezone <IANA>] [--json] [--alerts] [--admin-root <path>]
17
28
 
18
29
  Options:
19
30
  -h, --help Show help
@@ -23,6 +34,8 @@ Commands:
23
34
  inspect Report tito.yaml and AGENTS.md. Reads only.
24
35
  apply Dry-run an adoption plan. Writes are not available.
25
36
  init Install Tito files and specialist agents. Confirm before writing.
37
+ upgrade Install the latest Tito and replace Tito-owned files only.
38
+ admin Opt-in local repo index: add, list, remove, refresh, retro.
26
39
  `;
27
40
  function fail(message) {
28
41
  process.stderr.write(`${message}\n`);
@@ -174,6 +187,189 @@ async function runInit(args) {
174
187
  throw error;
175
188
  }
176
189
  }
190
+ async function runUpgrade(args) {
191
+ if (args.includes("-h") || args.includes("--help")) {
192
+ process.stdout.write(help);
193
+ return;
194
+ }
195
+ let root = process.cwd();
196
+ let confirm = false;
197
+ let filesOnly = false;
198
+ try {
199
+ const { values } = parseArgs({
200
+ args,
201
+ options: {
202
+ confirm: { type: "boolean" },
203
+ root: { type: "string" },
204
+ "files-only": { type: "boolean" },
205
+ },
206
+ strict: true,
207
+ allowPositionals: false,
208
+ });
209
+ confirm = values.confirm === true;
210
+ filesOnly = values["files-only"] === true;
211
+ if (values.root !== undefined)
212
+ root = values.root;
213
+ }
214
+ catch (error) {
215
+ fail(error instanceof Error ? error.message : "Invalid upgrade arguments.");
216
+ return;
217
+ }
218
+ const files = planUpgrade(root, titoOwnedFiles());
219
+ if (!confirm) {
220
+ process.stdout.write(formatUpgrade(root, files));
221
+ return;
222
+ }
223
+ if (!filesOnly && !isTitoSource(root)) {
224
+ const install = spawnSync("npm", ["install", "-D", "@anhvupt/tito@latest"], { cwd: root, encoding: "utf8" });
225
+ if (install.status !== 0) {
226
+ fail(install.stderr || "Could not install the latest Tito.");
227
+ return;
228
+ }
229
+ const installed = join(root, "node_modules/@anhvupt/tito/dist/cli.js");
230
+ const child = spawnSync(process.execPath, [installed, "upgrade", "--confirm", "--files-only", "--root", root], { encoding: "utf8" });
231
+ process.stdout.write(child.stdout ?? "");
232
+ if (child.stderr)
233
+ process.stderr.write(child.stderr);
234
+ process.exitCode = child.status ?? 1;
235
+ return;
236
+ }
237
+ applyUpgrade(root, files);
238
+ process.stdout.write(formatUpgrade(root, files));
239
+ }
240
+ function isTitoSource(root) {
241
+ try {
242
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
243
+ return manifest.name === "@anhvupt/tito";
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
249
+ function runAdmin(args) {
250
+ if (args.includes("-h") || args.includes("--help") || args.length === 0) {
251
+ process.stdout.write(help);
252
+ return;
253
+ }
254
+ const action = args[0];
255
+ const rest = args.slice(1);
256
+ if (action === "retro") {
257
+ runRetro(rest);
258
+ return;
259
+ }
260
+ if (action !== "add" &&
261
+ action !== "list" &&
262
+ action !== "remove" &&
263
+ action !== "refresh") {
264
+ fail(`Unknown admin action: ${action}`);
265
+ return;
266
+ }
267
+ let root = process.cwd();
268
+ let adminRoot = resolveAdminRoot();
269
+ let rootProvided = false;
270
+ try {
271
+ const { values } = parseArgs({
272
+ args: rest,
273
+ options: {
274
+ root: { type: "string" },
275
+ "admin-root": { type: "string" },
276
+ },
277
+ strict: true,
278
+ allowPositionals: false,
279
+ });
280
+ if (values.root !== undefined) {
281
+ root = values.root;
282
+ rootProvided = true;
283
+ }
284
+ if (values["admin-root"] !== undefined) {
285
+ adminRoot = resolveAdminRoot(values["admin-root"]);
286
+ }
287
+ }
288
+ catch (error) {
289
+ fail(error instanceof Error ? error.message : "Invalid admin arguments.");
290
+ return;
291
+ }
292
+ try {
293
+ if (action === "add") {
294
+ const result = addRepo(adminRoot, root);
295
+ process.stdout.write(result.created
296
+ ? `added: ${result.repo.path}\n`
297
+ : `kept: ${result.repo.path}\n`);
298
+ return;
299
+ }
300
+ if (action === "list") {
301
+ process.stdout.write(formatRepos(listRepos(adminRoot)));
302
+ return;
303
+ }
304
+ if (action === "remove") {
305
+ if (!rootProvided) {
306
+ fail("Missing required option --root.");
307
+ return;
308
+ }
309
+ const removed = removeRepo(adminRoot, root);
310
+ process.stdout.write(`removed: ${removed.path}\n`);
311
+ return;
312
+ }
313
+ process.stdout.write(formatContexts(refreshRepos(adminRoot)));
314
+ }
315
+ catch (error) {
316
+ if (error instanceof AdminError) {
317
+ fail(`${error.code}: ${error.message}`);
318
+ return;
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+ function runRetro(args) {
324
+ let week;
325
+ let timezone;
326
+ let json = false;
327
+ let alerts = false;
328
+ let adminRoot = resolveAdminRoot();
329
+ try {
330
+ const { values } = parseArgs({
331
+ args,
332
+ options: {
333
+ week: { type: "string" },
334
+ timezone: { type: "string" },
335
+ json: { type: "boolean" },
336
+ alerts: { type: "boolean" },
337
+ "admin-root": { type: "string" },
338
+ },
339
+ strict: true,
340
+ allowPositionals: false,
341
+ });
342
+ if (values.week !== undefined)
343
+ week = values.week;
344
+ if (values.timezone !== undefined)
345
+ timezone = values.timezone;
346
+ json = values.json === true;
347
+ alerts = values.alerts === true;
348
+ if (values["admin-root"] !== undefined) {
349
+ adminRoot = resolveAdminRoot(values["admin-root"]);
350
+ }
351
+ }
352
+ catch (error) {
353
+ fail(error instanceof Error ? error.message : "Invalid admin arguments.");
354
+ return;
355
+ }
356
+ try {
357
+ const report = buildRetro({
358
+ adminRoot,
359
+ ...(week !== undefined ? { week } : {}),
360
+ ...(timezone !== undefined ? { timezone } : {}),
361
+ });
362
+ saveRetro(adminRoot, report);
363
+ process.stdout.write(json ? retroJson(report) : formatRetro(report, { alerts }));
364
+ }
365
+ catch (error) {
366
+ if (error instanceof RetroError || error instanceof AdminError) {
367
+ fail(`${error.code}: ${error.message}`);
368
+ return;
369
+ }
370
+ throw error;
371
+ }
372
+ }
177
373
  async function run(args) {
178
374
  if (args[0] === "inspect") {
179
375
  runInspect(args.slice(1));
@@ -186,6 +382,13 @@ async function run(args) {
186
382
  if (args[0] === "init") {
187
383
  return runInit(args.slice(1));
188
384
  }
385
+ if (args[0] === "upgrade") {
386
+ return runUpgrade(args.slice(1));
387
+ }
388
+ if (args[0] === "admin") {
389
+ runAdmin(args.slice(1));
390
+ return;
391
+ }
189
392
  let helpRequested = false;
190
393
  let versionRequested = false;
191
394
  try {
@@ -0,0 +1,241 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ export class AdminError extends Error {
6
+ code;
7
+ path;
8
+ constructor(code, path, message) {
9
+ super(message);
10
+ this.name = "AdminError";
11
+ this.code = code;
12
+ this.path = path;
13
+ }
14
+ }
15
+ const SECRET_BASENAMES = new Set([".env", "credentials.json", "id_rsa"]);
16
+ export function isSecretFilename(filePath) {
17
+ const name = basename(filePath);
18
+ if (SECRET_BASENAMES.has(name))
19
+ return true;
20
+ return name.startsWith(".env.");
21
+ }
22
+ export function resolveAdminRoot(explicit) {
23
+ if (explicit !== undefined)
24
+ return resolve(explicit);
25
+ return join(homedir(), ".config", "tito", "admin");
26
+ }
27
+ export function reposFilePath(adminRoot) {
28
+ return join(resolve(adminRoot), "repos.json");
29
+ }
30
+ export function contextFilePath(adminRoot) {
31
+ return join(resolve(adminRoot), "context.json");
32
+ }
33
+ function errno(error) {
34
+ if (typeof error === "object" &&
35
+ error !== null &&
36
+ "code" in error &&
37
+ typeof error.code === "string") {
38
+ return error.code;
39
+ }
40
+ return null;
41
+ }
42
+ function assertDirectory(path) {
43
+ const resolved = resolve(path);
44
+ try {
45
+ if (!statSync(resolved).isDirectory()) {
46
+ throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
47
+ }
48
+ }
49
+ catch (error) {
50
+ if (error instanceof AdminError)
51
+ throw error;
52
+ const code = errno(error);
53
+ if (code === "ENOENT") {
54
+ throw new AdminError("not-a-directory", resolved, `${resolved} is not a directory.`);
55
+ }
56
+ throw new AdminError("filesystem", resolved, `Cannot inspect ${resolved}.`);
57
+ }
58
+ return resolved;
59
+ }
60
+ function writeJsonAtomic(path, value) {
61
+ mkdirSync(dirname(path), { recursive: true });
62
+ const tmp = `${path}.${process.pid}.tmp`;
63
+ try {
64
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
65
+ renameSync(tmp, path);
66
+ }
67
+ catch (error) {
68
+ throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot write ${path}.`);
69
+ }
70
+ }
71
+ function readJsonFile(path) {
72
+ try {
73
+ return JSON.parse(readFileSync(path, "utf8"));
74
+ }
75
+ catch (error) {
76
+ const code = errno(error);
77
+ if (code === "ENOENT")
78
+ return null;
79
+ throw new AdminError("filesystem", path, error instanceof Error ? error.message : `Cannot read ${path}.`);
80
+ }
81
+ }
82
+ function isAdminRepo(value) {
83
+ if (typeof value !== "object" || value === null)
84
+ return false;
85
+ const record = value;
86
+ return typeof record.path === "string" && typeof record.addedAt === "string";
87
+ }
88
+ export function loadRepos(adminRoot) {
89
+ const path = reposFilePath(adminRoot);
90
+ if (!existsSync(path))
91
+ return [];
92
+ const raw = readJsonFile(path);
93
+ if (!Array.isArray(raw)) {
94
+ throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
95
+ }
96
+ const repos = [];
97
+ for (const item of raw) {
98
+ if (!isAdminRepo(item)) {
99
+ throw new AdminError("filesystem", path, `${path} has an invalid repo entry.`);
100
+ }
101
+ repos.push({ path: item.path, addedAt: item.addedAt });
102
+ }
103
+ return repos;
104
+ }
105
+ export function saveRepos(adminRoot, repos) {
106
+ writeJsonAtomic(reposFilePath(adminRoot), repos);
107
+ }
108
+ export function loadContexts(adminRoot) {
109
+ const path = contextFilePath(adminRoot);
110
+ if (!existsSync(path))
111
+ return [];
112
+ const raw = readJsonFile(path);
113
+ if (!Array.isArray(raw)) {
114
+ throw new AdminError("filesystem", path, `${path} must contain a JSON array.`);
115
+ }
116
+ return raw;
117
+ }
118
+ export function saveContexts(adminRoot, contexts) {
119
+ writeJsonAtomic(contextFilePath(adminRoot), contexts);
120
+ }
121
+ const defaultClock = () => new Date().toISOString();
122
+ export function defaultGitRunner(cwd, args) {
123
+ const result = spawnSync("git", [...args], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ });
127
+ if (result.error) {
128
+ throw new AdminError("git", cwd, result.error.message);
129
+ }
130
+ if (result.status !== 0) {
131
+ const detail = (result.stderr || result.stdout || "git command failed").trim();
132
+ throw new AdminError("git", cwd, detail);
133
+ }
134
+ return result.stdout ?? "";
135
+ }
136
+ export function addRepo(adminRoot, repoPath, options = {}) {
137
+ const path = assertDirectory(repoPath);
138
+ const repos = loadRepos(adminRoot);
139
+ const existing = repos.find((repo) => repo.path === path);
140
+ if (existing !== undefined) {
141
+ return { repo: existing, created: false };
142
+ }
143
+ const clock = options.clock ?? defaultClock;
144
+ const repo = { path, addedAt: clock() };
145
+ repos.push(repo);
146
+ saveRepos(adminRoot, repos);
147
+ return { repo, created: true };
148
+ }
149
+ export function listRepos(adminRoot) {
150
+ return loadRepos(adminRoot);
151
+ }
152
+ export function removeRepo(adminRoot, repoPath) {
153
+ const path = resolve(repoPath);
154
+ const repos = loadRepos(adminRoot);
155
+ const index = repos.findIndex((repo) => repo.path === path);
156
+ if (index < 0) {
157
+ throw new AdminError("not-registered", path, `${path} is not registered.`);
158
+ }
159
+ const [removed] = repos.splice(index, 1);
160
+ if (removed === undefined) {
161
+ throw new AdminError("not-registered", path, `${path} is not registered.`);
162
+ }
163
+ saveRepos(adminRoot, repos);
164
+ return removed;
165
+ }
166
+ function parseCommitLog(stdout) {
167
+ const commits = [];
168
+ const blocks = stdout.split(/\n(?=◆)/);
169
+ for (const block of blocks) {
170
+ const trimmed = block.trim();
171
+ if (trimmed.length === 0)
172
+ continue;
173
+ const lines = trimmed.split("\n");
174
+ const header = lines[0];
175
+ if (header === undefined || !header.startsWith("◆"))
176
+ continue;
177
+ const parts = header.slice(1).split("\0");
178
+ const subject = parts[0] ?? "";
179
+ const date = parts[1] ?? "";
180
+ const files = [];
181
+ for (const line of lines.slice(1)) {
182
+ const file = line.trim();
183
+ if (file.length === 0)
184
+ continue;
185
+ if (isSecretFilename(file))
186
+ continue;
187
+ files.push(file);
188
+ }
189
+ commits.push({ subject, date, files });
190
+ }
191
+ return commits;
192
+ }
193
+ export function readRepoContext(repoPath, options = {}) {
194
+ const path = assertDirectory(repoPath);
195
+ const git = options.git ?? defaultGitRunner;
196
+ const branch = git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
197
+ const log = git(path, [
198
+ "log",
199
+ "-n",
200
+ "50",
201
+ "--pretty=format:◆%s%x00%cI",
202
+ "--name-only",
203
+ ]);
204
+ return {
205
+ path,
206
+ branch,
207
+ recentCommits: parseCommitLog(log),
208
+ };
209
+ }
210
+ export function refreshRepos(adminRoot, options = {}) {
211
+ const repos = loadRepos(adminRoot);
212
+ const contexts = [];
213
+ for (const repo of repos) {
214
+ contexts.push(readRepoContext(repo.path, options));
215
+ }
216
+ saveContexts(adminRoot, contexts);
217
+ return contexts;
218
+ }
219
+ export function formatRepos(repos) {
220
+ if (repos.length === 0)
221
+ return "No registered repositories.\n";
222
+ return `${repos.map((repo) => `${repo.path}\t${repo.addedAt}`).join("\n")}\n`;
223
+ }
224
+ export function formatContexts(contexts) {
225
+ if (contexts.length === 0)
226
+ return "No repository context.\n";
227
+ const lines = [];
228
+ for (const context of contexts) {
229
+ lines.push(`path: ${context.path}`);
230
+ lines.push(`branch: ${context.branch}`);
231
+ lines.push(`commits: ${context.recentCommits.length}`);
232
+ for (const commit of context.recentCommits) {
233
+ lines.push(`- ${commit.date}\t${commit.subject}`);
234
+ for (const file of commit.files) {
235
+ lines.push(` ${file}`);
236
+ }
237
+ }
238
+ lines.push("");
239
+ }
240
+ return `${lines.join("\n").trimEnd()}\n`;
241
+ }