pirl-framework-cli 0.0.0-stage → 1.0.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -2
  4. package/bundled-framework/files/.agents/evals/layer_record.py +438 -0
  5. package/bundled-framework/files/.agents/evals/tests/test_layer_record.py +264 -0
  6. package/bundled-framework/files/.agents/evals/tests/test_skills.py +210 -0
  7. package/bundled-framework/files/.agents/evals/tests/test_validate_layer_artifact.py +214 -0
  8. package/bundled-framework/files/.agents/evals/validate_layer_artifact.py +415 -0
  9. package/bundled-framework/files/.agents/instructions/implement.instructions.md +343 -0
  10. package/bundled-framework/files/.agents/instructions/layered-task-execution.instructions.md +370 -0
  11. package/bundled-framework/files/.agents/instructions/layout-verification.instructions.md +107 -0
  12. package/bundled-framework/files/.agents/instructions/plan.instructions.md +292 -0
  13. package/bundled-framework/files/.agents/instructions/review.instructions.md +236 -0
  14. package/bundled-framework/files/.agents/instructions/self-evaluation.instructions.md +146 -0
  15. package/bundled-framework/files/.agents/instructions/standalone-and-maintenance.instructions.md +230 -0
  16. package/bundled-framework/files/.agents/roles/backend-developer.role.md +45 -0
  17. package/bundled-framework/files/.agents/roles/business-analyst.role.md +55 -0
  18. package/bundled-framework/files/.agents/roles/code-analyst.role.md +63 -0
  19. package/bundled-framework/files/.agents/roles/code-reviewer.role.md +46 -0
  20. package/bundled-framework/files/.agents/roles/frontend-developer.role.md +51 -0
  21. package/bundled-framework/files/.agents/roles/process-auditor.role.md +62 -0
  22. package/bundled-framework/files/.agents/roles/solution-architect.role.md +53 -0
  23. package/bundled-framework/files/.agents/roles/technical-writer.role.md +68 -0
  24. package/bundled-framework/files/.agents/roles/ux-designer.role.md +46 -0
  25. package/bundled-framework/files/.skills/pirl-implement/SKILL.md +115 -0
  26. package/bundled-framework/files/.skills/pirl-implement/assets/implementation-record-template.md +22 -0
  27. package/bundled-framework/files/.skills/pirl-implement/references/implementation-record-format.md +41 -0
  28. package/bundled-framework/files/.skills/pirl-implement/scripts/add_implementation_record.py +58 -0
  29. package/bundled-framework/files/.skills/pirl-implement/scripts/new_layout_run.py +51 -0
  30. package/bundled-framework/files/.skills/pirl-plan/SKILL.md +100 -0
  31. package/bundled-framework/files/.skills/pirl-plan/assets/plan-template.md +48 -0
  32. package/bundled-framework/files/.skills/pirl-plan/references/plan-format.md +65 -0
  33. package/bundled-framework/files/.skills/pirl-plan/scripts/new_plan.py +93 -0
  34. package/bundled-framework/files/.skills/pirl-review/SKILL.md +106 -0
  35. package/bundled-framework/files/.skills/pirl-review/assets/review-template.md +92 -0
  36. package/bundled-framework/files/.skills/pirl-review/references/review-format.md +70 -0
  37. package/bundled-framework/files/.skills/pirl-review/scripts/new_review.py +83 -0
  38. package/bundled-framework/files/AGENTS.md +303 -0
  39. package/bundled-framework/files/CONTEXT.md +187 -0
  40. package/bundled-framework/files/LICENSE +21 -0
  41. package/bundled-framework/files/README.md +170 -0
  42. package/bundled-framework/files/gitignore.pirl-bundled +12 -0
  43. package/bundled-framework/manifest.json +45 -0
  44. package/dist/bin/main.js +128 -0
  45. package/dist/bin/pirl.js +6 -0
  46. package/dist/commands/dispatch.js +57 -0
  47. package/dist/commands/registry.js +39 -0
  48. package/dist/core/clipboard.js +70 -0
  49. package/dist/core/config.js +73 -0
  50. package/dist/core/exec.js +32 -0
  51. package/dist/core/paths.js +34 -0
  52. package/dist/core/targetRepo.js +22 -0
  53. package/dist/doctor/doctor.js +95 -0
  54. package/dist/engine/agentMerge.js +71 -0
  55. package/dist/engine/agents.js +67 -0
  56. package/dist/engine/attachments.js +251 -0
  57. package/dist/engine/claudeQuota.js +62 -0
  58. package/dist/engine/claudeRunner.js +118 -0
  59. package/dist/engine/claudeStream.js +205 -0
  60. package/dist/engine/hubData.js +176 -0
  61. package/dist/engine/permissions.js +67 -0
  62. package/dist/engine/pricing.js +24 -0
  63. package/dist/engine/protocol.js +98 -0
  64. package/dist/engine/run.js +527 -0
  65. package/dist/engine/runStore.js +67 -0
  66. package/dist/engine/types.js +3 -0
  67. package/dist/hub/framework.js +13 -0
  68. package/dist/hub/hub.js +125 -0
  69. package/dist/hub/services.js +38 -0
  70. package/dist/hub/update.js +261 -0
  71. package/dist/providers/claude.js +62 -0
  72. package/dist/providers/index.js +17 -0
  73. package/dist/providers/types.js +1 -0
  74. package/dist/ui/App.js +849 -0
  75. package/dist/ui/browse.js +144 -0
  76. package/dist/ui/components/Header.js +54 -0
  77. package/dist/ui/components/Input.js +76 -0
  78. package/dist/ui/components/Lines.js +50 -0
  79. package/dist/ui/components/Panels.js +73 -0
  80. package/dist/ui/content.js +225 -0
  81. package/dist/ui/frameworkConsole.js +541 -0
  82. package/dist/ui/inputEditor.js +86 -0
  83. package/dist/ui/layout.js +36 -0
  84. package/dist/ui/lines.js +15 -0
  85. package/dist/ui/listOverlay.js +97 -0
  86. package/dist/ui/runView.js +305 -0
  87. package/dist/ui/session.js +1 -0
  88. package/dist/ui/terminalInput.js +45 -0
  89. package/dist/ui/text.js +58 -0
  90. package/dist/ui/theme.js +139 -0
  91. package/dist/ui/ticker.js +48 -0
  92. package/package.json +67 -4
@@ -0,0 +1,34 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /** `~/.pirl` (`%USERPROFILE%\.pirl` on Windows). PIRL_HOME moves it, which tests use to stay off the real home. */
6
+ export function pirlHome(env = process.env) {
7
+ return env['PIRL_HOME'] || join(homedir(), '.pirl');
8
+ }
9
+ export const hubDir = (home) => join(home, 'hub');
10
+ /** Paths under the user's home are shown as `~/…`, which keeps them short and readable in the TUI. */
11
+ export function displayPath(path, home = homedir()) {
12
+ if (!home)
13
+ return path;
14
+ if (path === home)
15
+ return '~';
16
+ // Either separator: Windows accepts both, and paths reach here from Git and from the user's shell.
17
+ return path.startsWith(home + sep) || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
18
+ }
19
+ export const configFile = (home) => join(home, 'config.json');
20
+ /** The directory holding this package's package.json, found from the running file (src/ in dev, dist/ when built). */
21
+ export function packageRoot(from = fileURLToPath(import.meta.url)) {
22
+ let dir = dirname(from);
23
+ while (!existsSync(join(dir, 'package.json'))) {
24
+ const parent = dirname(dir);
25
+ if (parent === dir)
26
+ throw new Error('package.json of PIRL CLI not found');
27
+ dir = parent;
28
+ }
29
+ return dir;
30
+ }
31
+ export function packageInfo(root = packageRoot()) {
32
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
33
+ return { name: pkg.name, version: pkg.version };
34
+ }
@@ -0,0 +1,22 @@
1
+ import { basename } from 'node:path';
2
+ import { exec as defaultExec, succeeded } from './exec.js';
3
+ /**
4
+ * The target repository is the Git repository holding the directory where `pirl` was started.
5
+ * From a subdirectory, its repository root is used. Outside any repository the TUI still opens,
6
+ * but the layers stay locked.
7
+ */
8
+ export async function detectTargetRepo(cwd, exec = defaultExec) {
9
+ const top = await exec('git', ['rev-parse', '--show-toplevel'], { cwd, timeoutMs: 10_000 });
10
+ if (top.error)
11
+ return { kind: 'none', cwd, reason: 'Không tìm thấy Git' };
12
+ if (!succeeded(top))
13
+ return { kind: 'none', cwd, reason: 'Thư mục hiện tại không nằm trong git repo' };
14
+ const root = top.stdout.trim();
15
+ const head = await exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: root, timeoutMs: 10_000 });
16
+ let branch = succeeded(head) ? head.stdout.trim() : null;
17
+ if (!branch) {
18
+ const sha = await exec('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, timeoutMs: 10_000 });
19
+ branch = succeeded(sha) ? `@${sha.stdout.trim()}` : null;
20
+ }
21
+ return { kind: 'git', root, name: basename(root), branch };
22
+ }
@@ -0,0 +1,95 @@
1
+ import { succeeded } from '../core/exec.js';
2
+ export const MIN_NODE_MAJOR = 22;
3
+ export const MIN_PYTHON = [3, 9];
4
+ export function nodeCheck(version = process.versions.node) {
5
+ const major = Number(version.split('.')[0]);
6
+ return major >= MIN_NODE_MAJOR
7
+ ? { id: 'node', label: 'Node.js', state: 'ok', detail: `v${version}` }
8
+ : { id: 'node', label: 'Node.js', state: 'fail', detail: `v${version}`, fix: `Cài Node.js ${MIN_NODE_MAJOR} trở lên.` };
9
+ }
10
+ export async function gitCheck(exec) {
11
+ const result = await exec('git', ['--version'], { timeoutMs: 10_000 });
12
+ return succeeded(result)
13
+ ? { id: 'git', label: 'Git', state: 'ok', detail: result.stdout.trim().replace(/^git version\s*/, '') }
14
+ : { id: 'git', label: 'Git', state: 'fail', detail: 'Không tìm thấy', fix: 'Cài Git và đảm bảo lệnh `git` chạy được trong terminal.' };
15
+ }
16
+ /** The Windows `py` launcher comes first there; the Microsoft Store `python3` stub fails the version parse and is skipped. */
17
+ export function pythonCandidates(platform = process.platform) {
18
+ const plain = [
19
+ { command: 'python3', args: [] },
20
+ { command: 'python', args: [] },
21
+ ];
22
+ return platform === 'win32' ? [{ command: 'py', args: ['-3'] }, ...plain.reverse()] : plain;
23
+ }
24
+ export function parsePythonVersion(output) {
25
+ const match = /Python (\d+)\.(\d+)(\.\d+)?/.exec(output);
26
+ return match ? [Number(match[1]), Number(match[2]), `${match[1]}.${match[2]}${match[3] ?? ''}`] : null;
27
+ }
28
+ export async function findPython(exec, platform = process.platform) {
29
+ let tooOld = null;
30
+ for (const candidate of pythonCandidates(platform)) {
31
+ const result = await exec(candidate.command, [...candidate.args, '--version'], { timeoutMs: 10_000 });
32
+ const parsed = succeeded(result) ? parsePythonVersion(result.stdout + result.stderr) : null;
33
+ if (!parsed)
34
+ continue;
35
+ const [major, minor, version] = parsed;
36
+ if (major > MIN_PYTHON[0] || (major === MIN_PYTHON[0] && minor >= MIN_PYTHON[1])) {
37
+ const python = { ...candidate, version };
38
+ return { python, check: { id: 'python', label: 'Python', state: 'ok', detail: `${version} (${[candidate.command, ...candidate.args].join(' ')})` } };
39
+ }
40
+ tooOld ??= version;
41
+ }
42
+ const need = `${MIN_PYTHON[0]}.${MIN_PYTHON[1]}`;
43
+ return {
44
+ python: null,
45
+ check: {
46
+ id: 'python',
47
+ label: 'Python',
48
+ state: 'fail',
49
+ detail: tooOld ? `${tooOld} quá cũ` : 'Không tìm thấy',
50
+ fix: `Cài Python ${need} trở lên; các công cụ của framework (validator, record) chạy bằng Python.`,
51
+ },
52
+ };
53
+ }
54
+ export async function providerCheck(provider, exec) {
55
+ const base = { id: 'provider', label: provider.label };
56
+ if (!provider.available)
57
+ return { ...base, state: 'fail', detail: 'Chưa được hỗ trợ', fix: 'Chọn Claude Code trong /settings.' };
58
+ const found = await provider.detect(exec);
59
+ if (!found.installed)
60
+ return { ...base, state: 'fail', detail: 'Chưa cài', fix: found.fix };
61
+ const version = found.version ? `${found.version} · ` : '';
62
+ if (found.loggedIn === false)
63
+ return { ...base, state: 'fail', detail: `${version}chưa đăng nhập`, fix: found.fix };
64
+ if (found.outdated)
65
+ return { ...base, state: 'fail', detail: `${version}cần bản mới hơn`, fix: found.fix };
66
+ if (found.loggedIn === null)
67
+ return { ...base, state: 'warn', detail: `${version}không xác định được trạng thái đăng nhập` };
68
+ return { ...base, state: 'ok', detail: `${version}đã đăng nhập${found.authMethod ? ` bằng ${found.authMethod}` : ''}` };
69
+ }
70
+ export function hubCheck(status, error) {
71
+ const base = { id: 'hub', label: 'Hub' };
72
+ if (!status)
73
+ return { ...base, state: 'fail', detail: error ?? 'Chưa tạo được Hub' };
74
+ if (status.branch !== 'main') {
75
+ return { ...base, state: 'warn', detail: `Đang ở nhánh ${status.branch ?? '(không có)'}`, fix: `Chuyển Hub (${status.path}) về nhánh main.` };
76
+ }
77
+ const parts = [status.upstreamTag ?? 'không rõ phiên bản', `${status.localImprovements} cải tiến cục bộ`];
78
+ if (status.uncommitted.length > 0)
79
+ parts.push(`${status.uncommitted.length} thay đổi chưa commit`);
80
+ if (status.updateAvailable)
81
+ parts.push(`có framework ${status.bundledVersion} mới`);
82
+ return { ...base, state: status.uncommitted.length > 0 ? 'warn' : 'ok', detail: parts.join(' · ') };
83
+ }
84
+ export function targetCheck(target) {
85
+ return target.kind === 'git'
86
+ ? { id: 'target', label: 'Target repo', state: 'ok', detail: `${target.root}${target.branch ? ` (${target.branch})` : ''}` }
87
+ : { id: 'target', label: 'Target repo', state: 'warn', detail: target.reason, fix: 'Mở `pirl` trong thư mục của một git repo để dùng các layer.' };
88
+ }
89
+ export async function runDoctor(input) {
90
+ const [git, python, provider] = await Promise.all([gitCheck(input.exec), findPython(input.exec), providerCheck(input.provider, input.exec)]);
91
+ return {
92
+ checks: [nodeCheck(), git, python.check, provider, hubCheck(input.hub, input.hubError), targetCheck(input.target)],
93
+ python: python.python,
94
+ };
95
+ }
@@ -0,0 +1,71 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { hasConflictMarkers } from '../hub/update.js';
4
+ import { ruleRoot, WAITING_TOOLS } from './permissions.js';
5
+ /** Where the three versions and the merged result live during the proposal, inside the draft's ignored `.tmp/`. */
6
+ const WORK = join('.tmp', 'pirl-merge');
7
+ const SYSTEM_PROMPT = (dir, path) => [
8
+ 'You merge one file of the PIRL framework during a framework update. Nothing else is in scope.',
9
+ `The file is \`${path}\`. Its versions are in ${dir}:`,
10
+ '- `base`: the original version both sides started from;',
11
+ '- `mine`: the user\'s version, carrying their local improvements;',
12
+ '- `theirs`: the new original version.',
13
+ `Read the three, then write the merged file to ${join(dir, 'merged')} with the Write tool.`,
14
+ 'Keep every change of `theirs` and every local improvement of `mine`. Where both change the same passage, write one passage that carries both intentions. When they contradict, prefer `theirs`, unless the local change makes a rule stricter.',
15
+ 'Never loosen, narrow or remove a safety rule (AGENTS.md, "Safety rules and self-improvement", defines them); where a safety rule differs between the versions, keep the stricter wording.',
16
+ 'The merged file holds only the file content: no conflict markers, no comments about the merge.',
17
+ 'Then reply in the language of the file\'s user, Vietnamese, in at most four short sentences: what each side changed and how you combined them.',
18
+ ].join('\n');
19
+ /**
20
+ * Asks the provider for a merged version of one conflicted file. Nothing is applied here: the proposal goes to the
21
+ * user, who accepts it or picks another resolution. The provider may read the draft and write only the result file.
22
+ */
23
+ export async function proposeMerge(runner, draftDir, conflict, model) {
24
+ const work = join(draftDir, WORK);
25
+ rmSync(work, { recursive: true, force: true });
26
+ mkdirSync(work, { recursive: true });
27
+ try {
28
+ writeFileSync(join(work, 'base'), conflict.base ?? '', 'utf8');
29
+ writeFileSync(join(work, 'mine'), conflict.mine ?? '', 'utf8');
30
+ writeFileSync(join(work, 'theirs'), conflict.theirs ?? '', 'utf8');
31
+ const improvements = conflict.myCommits.map(commit => `- ${commit.subject}${commit.record ? ` (record ${commit.record})` : ''}`);
32
+ const prompt = [
33
+ `Merge \`${conflict.path}\`.`,
34
+ ...(improvements.length ? ['The local improvements in `mine` came from:', ...improvements] : []),
35
+ ].join('\n');
36
+ const merged = join(work, 'merged');
37
+ let reply = null;
38
+ const handle = runner.start({
39
+ cwd: draftDir,
40
+ addDirs: [],
41
+ prompt,
42
+ systemPrompt: SYSTEM_PROMPT(work, conflict.path),
43
+ agents: {},
44
+ permissions: {
45
+ allow: ['Read', 'Grep', 'Glob', `Edit(${ruleRoot(merged)})`],
46
+ deny: ['Bash', 'Agent', 'WebFetch', 'WebSearch', ...WAITING_TOOLS],
47
+ },
48
+ model,
49
+ }, event => {
50
+ if (event.type === 'result')
51
+ reply = event;
52
+ });
53
+ const exit = await handle.done;
54
+ const result = reply;
55
+ if (!result || result.isError)
56
+ return { ok: false, error: result?.text || exit.stderr.trim().split('\n').slice(-2).join(' ') || `mã thoát ${exit.code}` };
57
+ let content;
58
+ try {
59
+ content = readFileSync(merged, 'utf8');
60
+ }
61
+ catch {
62
+ return { ok: false, error: 'Agent không ghi bản gộp.' };
63
+ }
64
+ if (hasConflictMarkers(content))
65
+ return { ok: false, error: 'Bản gộp của agent vẫn còn dấu xung đột.' };
66
+ return { ok: true, content, explanation: result.text.trim() };
67
+ }
68
+ finally {
69
+ rmSync(work, { recursive: true, force: true });
70
+ }
71
+ }
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const CATALOG_FILE = '.agents/instructions/standalone-and-maintenance.instructions.md';
4
+ /** Reads the role catalog table of the Hub's framework, the only list of role IDs. */
5
+ export function readRoleCatalog(hub) {
6
+ const text = readFileSync(join(hub, CATALOG_FILE), 'utf8');
7
+ const section = text.slice(text.indexOf('### Role catalog'));
8
+ const roles = [];
9
+ for (const line of section.split('\n')) {
10
+ const match = /^\|\s*`([a-z-]+)`\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$/.exec(line);
11
+ if (match)
12
+ roles.push({ id: match[1], expertise: match[2], usedIn: match[3].split(',').map(part => part.trim()) });
13
+ }
14
+ return roles;
15
+ }
16
+ /**
17
+ * The tools each role's subagent gets. Implementation roles edit files; the roles that trace code may run
18
+ * read-only Git commands; process-auditor improves framework files and runs the framework's checks.
19
+ * Which paths may be edited and which commands may run is limited further by the run's permission rules.
20
+ */
21
+ export function roleTools(role) {
22
+ const read = ['Read', 'Grep', 'Glob'];
23
+ if (role.id === 'process-auditor')
24
+ return [...read, 'Edit', 'Write', 'Bash'];
25
+ if (role.usedIn.includes('implement'))
26
+ return role.id === 'technical-writer' ? [...read, 'Edit', 'Write'] : [...read, 'Edit', 'Write', 'Bash'];
27
+ if (role.id === 'code-analyst' || role.id === 'code-reviewer')
28
+ return [...read, 'Bash'];
29
+ return read;
30
+ }
31
+ /**
32
+ * How a role uses its tools under the run's permission rules (src/engine/permissions.ts). Subagents do not see the
33
+ * coordinator's host instructions, and a refused command costs a turn, so each role gets these rules itself.
34
+ */
35
+ function toolRules(target) {
36
+ return [
37
+ 'Read files with the Read, Grep and Glob tools, never with `cat`, `ls`, `grep`, `find` or `head` in Bash: the run refuses those.',
38
+ 'Bash runs one command per call, without pipes or `;`. It is allowed only for: read-only Git (`log`, `show`, `diff`, `status`, `ls-files`, `rev-parse`, `blame`), the framework\'s Python tools run from the Hub root, and the test command the target repository documents.',
39
+ ...(target ? [`In the target repository, run Git as \`git -C ${target} <command>\` and tests as \`cd ${target} && <test command>\`.`] : []),
40
+ 'Temporary files go under `.tmp/pirl-run/` in the Hub; `/tmp` and every path outside the Hub and the target repository are refused.',
41
+ ];
42
+ }
43
+ /**
44
+ * `model` is the roles' model alias; they run on the coordinator's model when it is left out. `target` is the run's
45
+ * target repository, named in the roles' command rules.
46
+ */
47
+ export function roleAgents(hub, roles = readRoleCatalog(hub), model, target = null, effort) {
48
+ return Object.fromEntries(roles.map(role => [
49
+ role.id,
50
+ {
51
+ description: `PIRL role ${role.id}: ${role.expertise}. Delegate this role's assignment of a PIRL layer run to it.`,
52
+ prompt: [
53
+ `You are the PIRL role \`${role.id}\`.`,
54
+ `Before anything else, read \`${join(hub, '.agents', 'roles', `${role.id}.role.md`)}\` and apply it.`,
55
+ 'Work only on the assignment the coordinator gave you, with the sources, constraints and settled direction it names.',
56
+ 'Support every conclusion with its source: file path and symbol, command and result, or the rule that applies.',
57
+ 'Do not read drafts or conclusions of other roles of this run unless your assignment hands them to you.',
58
+ 'Report every reviewer outside your role that you consulted. Never commit, push or change files your assignment does not cover.',
59
+ ...toolRules(target),
60
+ ].join('\n'),
61
+ tools: roleTools(role),
62
+ ...(model ? { model } : {}),
63
+ // Set on every role, not left to inheritance, so the roles reason at the level the user chose.
64
+ ...(effort ? { effort } : {}),
65
+ },
66
+ ]));
67
+ }
@@ -0,0 +1,251 @@
1
+ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, relative, resolve, sep, win32 } from 'node:path';
4
+ import { exec as defaultExec, succeeded } from '../core/exec.js';
5
+ /** Directories never listed: version control, dependencies and caches, which a repository's .gitignore usually leaves out. */
6
+ const SKIPPED = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.tox', '.mypy_cache', '.pytest_cache', '.next', '.turbo', '.cache']);
7
+ /** Most entries an index or a folder listing walks; a bigger tree is cut there. */
8
+ export const WALK_LIMIT = 20_000;
9
+ /** Files under `dir` (relative, `/` separators) by walking it, skipping SKIPPED, up to `limit` files. */
10
+ function walk(dir, limit) {
11
+ const files = [];
12
+ const pending = [''];
13
+ while (pending.length > 0 && files.length < limit) {
14
+ const current = pending.shift();
15
+ let names;
16
+ try {
17
+ names = readdirSync(join(dir, current), { withFileTypes: true });
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ names.sort((a, b) => a.name.localeCompare(b.name));
23
+ for (const entry of names) {
24
+ const path = current ? `${current}/${entry.name}` : entry.name;
25
+ if (entry.isDirectory()) {
26
+ if (!SKIPPED.has(entry.name))
27
+ pending.push(path);
28
+ }
29
+ else if (entry.isFile()) {
30
+ files.push(path);
31
+ if (files.length >= limit)
32
+ break;
33
+ }
34
+ }
35
+ }
36
+ return files;
37
+ }
38
+ /** Files of `dir`: those Git tracks or would track (so .gitignore applies) inside a repository, else a walk. */
39
+ async function filesOf(dir, exec) {
40
+ const listed = await exec('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: dir, timeoutMs: 20_000 });
41
+ if (succeeded(listed))
42
+ return listed.stdout.split('\0').filter(Boolean).slice(0, WALK_LIMIT);
43
+ return walk(dir, WALK_LIMIT);
44
+ }
45
+ /** The files under `base` and every folder holding them, for completing `@` mentions. */
46
+ export async function pathIndex(base, exec = defaultExec) {
47
+ const files = await filesOf(base, exec);
48
+ const folders = new Set();
49
+ for (const file of files) {
50
+ for (let at = file.indexOf('/'); at > 0; at = file.indexOf('/', at + 1))
51
+ folders.add(file.slice(0, at));
52
+ }
53
+ return [...[...folders].map(path => ({ path, folder: true })), ...files.map(path => ({ path, folder: false }))];
54
+ }
55
+ const basename = (path) => path.slice(path.lastIndexOf('/') + 1);
56
+ /**
57
+ * Paths for a mention being typed, best first. Nothing typed lists the top level; `folder/` lists that folder,
58
+ * itself first so Enter can attach it. Otherwise: paths starting with the text, then names starting with it,
59
+ * then names and paths containing it; folders first, shorter paths first.
60
+ */
61
+ export function suggestPaths(index, query, limit = 50) {
62
+ const typed = query.toLowerCase().replace(/\\/g, '/');
63
+ const byOrder = (a, b) => Number(b.folder) - Number(a.folder) || a.path.length - b.path.length || a.path.localeCompare(b.path);
64
+ if (typed === '' || typed.endsWith('/')) {
65
+ const parent = typed.replace(/\/$/, '');
66
+ const self = index.find(entry => entry.folder && entry.path.toLowerCase() === parent);
67
+ const children = index.filter(entry => {
68
+ const path = entry.path.toLowerCase();
69
+ if (parent && !path.startsWith(`${parent}/`))
70
+ return false;
71
+ return !path.slice(parent ? parent.length + 1 : 0).includes('/');
72
+ });
73
+ return [...(self ? [self] : []), ...children.sort(byOrder)].slice(0, limit);
74
+ }
75
+ const rank = (entry) => {
76
+ const path = entry.path.toLowerCase();
77
+ const name = basename(path);
78
+ if (path.startsWith(typed))
79
+ return 0;
80
+ if (name.startsWith(typed))
81
+ return 1;
82
+ if (name.includes(typed))
83
+ return 2;
84
+ if (path.includes(typed))
85
+ return 3;
86
+ return -1;
87
+ };
88
+ return index
89
+ .map(entry => ({ entry, score: rank(entry) }))
90
+ .filter(item => item.score >= 0)
91
+ .sort((a, b) => a.score - b.score || byOrder(a.entry, b.entry))
92
+ .slice(0, limit)
93
+ .map(item => item.entry);
94
+ }
95
+ /** How a path is written after `@`: quoted when it has spaces; a folder ends with `/`. */
96
+ export const mentionText = (entry) => {
97
+ const path = entry.folder ? `${entry.path}/` : entry.path;
98
+ return /\s/.test(path) ? `@"${path}"` : `@${path}`;
99
+ };
100
+ /** Every `@path` and `@"path"` in the text, written after a space or at the start (so an e-mail address is not one). */
101
+ export function findMentions(text) {
102
+ const found = [];
103
+ for (const match of text.matchAll(/(^|\s)@(?:"([^"]+)"|([^\s"]+))/g))
104
+ found.push(match[2] ?? match[3]);
105
+ return found;
106
+ }
107
+ /**
108
+ * The share or device a Windows path starts with (`\\host\share\`, `\\?\C:\`, `\\.\pipe\`), or null for a
109
+ * local path. Windows takes any two separators at the start as one, so `//host/share` and `/\host\share` count too.
110
+ * Elsewhere `//tmp/x` is an ordinary local path and `\` an ordinary character: null.
111
+ */
112
+ export function remoteRoot(path, platform = process.platform) {
113
+ if (platform !== 'win32' || !/^[\\/]{2}/.test(path))
114
+ return null;
115
+ return win32.parse(win32.normalize(path)).root.toLowerCase();
116
+ }
117
+ /**
118
+ * The path a mention names, if it exists: `~/` is the home directory; a relative path is read from `base`, then from `cwd`.
119
+ * On Windows a path on a network share or a device is never looked at unless the base, the start directory or the
120
+ * home directory is on that same share: merely reading it would connect to the host (SMB, or WebDAV for
121
+ * `\\host@80\x`) and send it the user's NTLM credentials, and a mention can come from pasted text.
122
+ */
123
+ function locate(mention, base, cwd, platform) {
124
+ const candidates = mention.startsWith('~/') || mention === '~' ? [join(homedir(), mention.slice(1))] : isAbsolute(mention) ? [resolve(mention)] : [resolve(base, mention), resolve(cwd, mention)];
125
+ const trusted = [base, cwd, homedir()].map(dir => remoteRoot(dir, platform)).filter((root) => root !== null);
126
+ const untrusted = (path) => {
127
+ const root = remoteRoot(path, platform);
128
+ return root !== null && !trusted.includes(root);
129
+ };
130
+ // The mention as written and each path it resolves to: either one naming a share is enough to skip it.
131
+ if (untrusted(mention))
132
+ return null;
133
+ for (const candidate of candidates) {
134
+ if (untrusted(candidate))
135
+ continue;
136
+ try {
137
+ const stat = statSync(candidate);
138
+ if (stat.isDirectory())
139
+ return { path: candidate, kind: 'folder', size: 0 };
140
+ if (stat.isFile())
141
+ return { path: candidate, kind: 'file', size: stat.size };
142
+ }
143
+ catch {
144
+ // not this one
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+ /**
150
+ * The mentions of the text that name an existing file or folder, once each. A mention followed by punctuation
151
+ * ("xem @src/a.ts, rồi…") still names the path without it. Mentions that name nothing stay plain text.
152
+ */
153
+ export function resolveMentions(text, base, cwd = base, platform = process.platform) {
154
+ const attachments = [];
155
+ const seen = new Set();
156
+ for (const mention of findMentions(text)) {
157
+ const trimmed = mention.replace(/[.,;:!?)\]}'"]+$/, '');
158
+ const found = locate(mention, base, cwd, platform) ?? (trimmed && trimmed !== mention ? locate(trimmed, base, cwd, platform) : null);
159
+ if (!found || seen.has(found.path))
160
+ continue;
161
+ seen.add(found.path);
162
+ const inside = relative(base, found.path);
163
+ const display = inside && !inside.startsWith('..') && !isAbsolute(inside) ? inside.split(sep).join('/') : found.path;
164
+ attachments.push({ mention, path: found.path, display: found.kind === 'folder' ? `${display.replace(/\/$/, '')}/` : display, kind: found.kind, size: found.size });
165
+ }
166
+ return attachments;
167
+ }
168
+ /** A file's content goes with the request up to this size; a bigger one is sent as its path, for the agent to read. */
169
+ export const FILE_LIMIT = 100_000;
170
+ /** All included contents together stay under this; files past it are sent as paths. */
171
+ export const TOTAL_LIMIT = 300_000;
172
+ /** Files a folder lists with its path; a bigger folder says how many more it holds. */
173
+ export const FOLDER_LIMIT = 300;
174
+ export function formatSize(bytes) {
175
+ if (bytes < 1024)
176
+ return `${bytes} B`;
177
+ if (bytes < 1024 * 1024)
178
+ return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
179
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
180
+ }
181
+ /** Text files only: a NUL byte in the first 8 KB marks a binary file (an image, a PDF, an archive). */
182
+ function isBinary(path) {
183
+ const buffer = Buffer.alloc(8192);
184
+ let fd = null;
185
+ try {
186
+ fd = openSync(path, 'r');
187
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
188
+ return buffer.subarray(0, read).includes(0);
189
+ }
190
+ catch {
191
+ return true;
192
+ }
193
+ finally {
194
+ if (fd !== null)
195
+ closeSync(fd);
196
+ }
197
+ }
198
+ /** A Markdown fence longer than any run of backticks in the text, so the text cannot close it. */
199
+ function fence(text) {
200
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map(match => match[0].length));
201
+ return '`'.repeat(longest + 1);
202
+ }
203
+ /** The request's context from its attachments: contents of text files within the limits, paths for the rest, file lists of folders. */
204
+ export async function attachmentContext(attachments, roots, exec = defaultExec) {
205
+ if (attachments.length === 0)
206
+ return { text: '', dirs: [], summary: [] };
207
+ const inside = (path) => roots.some(root => {
208
+ const rel = relative(root, path);
209
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
210
+ });
211
+ const dirs = new Set();
212
+ const summary = [];
213
+ const parts = [
214
+ '# Attached by the user',
215
+ '',
216
+ 'The user attached these paths with `@` in the request above. They are part of the request: read what bears on the task in full, and give the relevant paths to every role you delegate to, since roles do not see this message.',
217
+ ];
218
+ let total = 0;
219
+ for (const item of attachments) {
220
+ if (item.kind === 'folder' && !inside(item.path))
221
+ dirs.add(item.path);
222
+ if (item.kind === 'folder') {
223
+ const files = await filesOf(item.path, exec);
224
+ const shown = files.slice(0, FOLDER_LIMIT);
225
+ parts.push('', `## Folder: ${item.display} (${item.path}, ${files.length >= WALK_LIMIT ? `${WALK_LIMIT}+` : files.length} files)`, '');
226
+ parts.push(...shown.map(file => `- ${file}`));
227
+ if (files.length > shown.length)
228
+ parts.push(`- … and ${files.length - shown.length} more; list them with Glob.`);
229
+ summary.push({ display: item.display, detail: `thư mục, ${files.length} file: gửi danh sách file` });
230
+ continue;
231
+ }
232
+ const heading = `## File: ${item.display} (${item.path}, ${formatSize(item.size)})`;
233
+ if (isBinary(item.path)) {
234
+ parts.push('', heading, '', 'Not text (an image, a PDF or another binary file): read it with Read if it bears on the task.');
235
+ summary.push({ display: item.display, detail: `${formatSize(item.size)}, không phải văn bản: gửi đường dẫn` });
236
+ continue;
237
+ }
238
+ if (item.size > FILE_LIMIT || total + item.size > TOTAL_LIMIT) {
239
+ parts.push('', heading, '', 'Too large to include here: read it with Read, in parts where needed.');
240
+ summary.push({ display: item.display, detail: `${formatSize(item.size)}, quá lớn để gửi kèm: gửi đường dẫn`, tone: 'warning' });
241
+ continue;
242
+ }
243
+ const content = readFileSync(item.path, 'utf8');
244
+ total += item.size;
245
+ const marks = fence(content);
246
+ const name = basename(item.display);
247
+ parts.push('', heading, '', `${marks}${name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : ''}`, content.replace(/\n$/, ''), marks);
248
+ summary.push({ display: item.display, detail: `${formatSize(item.size)}: gửi kèm nội dung` });
249
+ }
250
+ return { text: parts.join('\n'), dirs: [...dirs], summary };
251
+ }
@@ -0,0 +1,62 @@
1
+ import spawn from 'cross-spawn';
2
+ const KIND_LABELS = { session: '5 giờ', weekly_all: 'Tuần' };
3
+ function labelOf(row) {
4
+ const kind = String(row['kind'] ?? '');
5
+ const scope = (row['scope'] ?? {});
6
+ const name = scope['model']?.display_name ?? scope['surface']?.display_name;
7
+ if (KIND_LABELS[kind])
8
+ return KIND_LABELS[kind];
9
+ if (String(row['group'] ?? '') === 'weekly' || kind.startsWith('weekly'))
10
+ return name ? `Tuần (${name})` : 'Tuần';
11
+ return name ?? kind;
12
+ }
13
+ /** The meters in one stream-json line, or null when the line has none (an API key login has no plan meters). */
14
+ export function parseQuotaLine(line) {
15
+ let data;
16
+ try {
17
+ data = JSON.parse(line);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ if (data['type'] !== 'assistant')
23
+ return null;
24
+ const message = (data['message'] ?? {});
25
+ const report = (data['usage_report'] ?? message['usage_report']);
26
+ const limits = report?.rate_limits?.limits;
27
+ if (!Array.isArray(limits))
28
+ return null;
29
+ return limits
30
+ .filter(row => typeof row['percent'] === 'number')
31
+ .map(row => ({ kind: String(row['kind'] ?? ''), label: labelOf(row), percent: row['percent'], resetsAt: typeof row['resets_at'] === 'string' ? row['resets_at'] : null }));
32
+ }
33
+ /** Null when the meters cannot be read: no subscription, an old Claude Code, a rate-limited usage endpoint. */
34
+ export function readClaudeQuota(command = 'claude', cwd, timeoutMs = 20_000) {
35
+ return new Promise(resolve => {
36
+ let rows = null;
37
+ let buffer = '';
38
+ const child = spawn(command, ['-p', '--output-format', 'stream-json', '--verbose', '--no-session-persistence'], {
39
+ cwd,
40
+ stdio: ['pipe', 'pipe', 'ignore'],
41
+ windowsHide: true,
42
+ });
43
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
44
+ child.stdout?.setEncoding('utf8').on('data', (chunk) => {
45
+ buffer += chunk;
46
+ const lines = buffer.split('\n');
47
+ buffer = lines.pop() ?? '';
48
+ for (const line of lines)
49
+ rows = parseQuotaLine(line) ?? rows;
50
+ });
51
+ child.once('error', () => {
52
+ clearTimeout(timer);
53
+ resolve(null);
54
+ });
55
+ child.once('close', () => {
56
+ clearTimeout(timer);
57
+ resolve(parseQuotaLine(buffer) ?? rows);
58
+ });
59
+ child.stdin?.on('error', () => { });
60
+ child.stdin?.end('/usage');
61
+ });
62
+ }