helloloop 0.1.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/.codex-plugin/plugin.json +33 -0
- package/README.md +334 -0
- package/bin/helloloop.mjs +8 -0
- package/package.json +37 -0
- package/scripts/helloloop.mjs +8 -0
- package/scripts/install-home-plugin.ps1 +30 -0
- package/skills/helloloop/SKILL.md +53 -0
- package/src/backlog.mjs +170 -0
- package/src/cli.mjs +262 -0
- package/src/common.mjs +66 -0
- package/src/config.mjs +141 -0
- package/src/context.mjs +56 -0
- package/src/doc_loader.mjs +106 -0
- package/src/install.mjs +130 -0
- package/src/lifecycle.mjs +58 -0
- package/src/process.mjs +264 -0
- package/src/prompt.mjs +116 -0
- package/src/runner.mjs +341 -0
- package/templates/STATE.template.md +12 -0
- package/templates/backlog.template.json +26 -0
- package/templates/policy.template.json +16 -0
- package/templates/project.template.json +14 -0
- package/templates/status.template.json +18 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
import { createContext } from "./context.mjs";
|
|
5
|
+
import { fileExists } from "./common.mjs";
|
|
6
|
+
import { scaffoldIfMissing } from "./config.mjs";
|
|
7
|
+
import { installPluginBundle } from "./install.mjs";
|
|
8
|
+
import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const [command = "status", ...rest] = argv;
|
|
12
|
+
const options = {
|
|
13
|
+
requiredDocs: [],
|
|
14
|
+
constraints: [],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
18
|
+
const arg = rest[index];
|
|
19
|
+
if (arg === "--dry-run") options.dryRun = true;
|
|
20
|
+
else if (arg === "--allow-high-risk") options.allowHighRisk = true;
|
|
21
|
+
else if (arg === "--force") options.force = true;
|
|
22
|
+
else if (arg === "--task-id") { options.taskId = rest[index + 1]; index += 1; }
|
|
23
|
+
else if (arg === "--max-tasks") { options.maxTasks = Number(rest[index + 1]); index += 1; }
|
|
24
|
+
else if (arg === "--max-attempts") { options.maxAttempts = Number(rest[index + 1]); index += 1; }
|
|
25
|
+
else if (arg === "--max-strategies") { options.maxStrategies = Number(rest[index + 1]); index += 1; }
|
|
26
|
+
else if (arg === "--repo") { options.repoRoot = rest[index + 1]; index += 1; }
|
|
27
|
+
else if (arg === "--codex-home") { options.codexHome = rest[index + 1]; index += 1; }
|
|
28
|
+
else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
|
|
29
|
+
else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
|
|
30
|
+
else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
|
|
31
|
+
else {
|
|
32
|
+
throw new Error(`未知参数:${arg}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { command, options };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function helpText() {
|
|
40
|
+
return [
|
|
41
|
+
"用法:helloloop <command> [options]",
|
|
42
|
+
"",
|
|
43
|
+
"命令:",
|
|
44
|
+
" install 安装插件到 Codex Home(适合 npx / npm bin 分发)",
|
|
45
|
+
" init 初始化 .helloloop 配置",
|
|
46
|
+
" status 查看 backlog 与下一任务",
|
|
47
|
+
" next 生成下一任务干跑预览",
|
|
48
|
+
" run-once 执行一个任务",
|
|
49
|
+
" run-loop 连续执行多个任务",
|
|
50
|
+
" doctor 检查 Codex、当前插件 bundle 与目标仓库 .helloloop 配置是否可用",
|
|
51
|
+
"",
|
|
52
|
+
"选项:",
|
|
53
|
+
" --codex-home <dir> Codex Home,install 默认使用 ~/.codex",
|
|
54
|
+
" --repo <dir> 目标仓库根目录,默认当前目录",
|
|
55
|
+
" --config-dir <dir> 配置目录,默认 .helloloop",
|
|
56
|
+
" --dry-run 只生成提示与预览,不真正调用 codex",
|
|
57
|
+
" --task-id <id> 指定任务 id",
|
|
58
|
+
" --max-tasks <n> run-loop 最多执行 n 个任务",
|
|
59
|
+
" --max-attempts <n> 每种策略内最多重试 n 次",
|
|
60
|
+
" --max-strategies <n> 单任务最多切换 n 种策略继续重试",
|
|
61
|
+
" --allow-high-risk 允许执行 medium/high/critical 风险任务",
|
|
62
|
+
" --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
|
|
63
|
+
" --constraint <text> 增加一个全局实现约束",
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printHelp() {
|
|
68
|
+
console.log(helpText());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function probeCodexVersion() {
|
|
72
|
+
const codexVersion = process.platform === "win32"
|
|
73
|
+
? spawnSync("codex --version", {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
shell: true,
|
|
76
|
+
})
|
|
77
|
+
: spawnSync("codex", ["--version"], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
shell: false,
|
|
80
|
+
});
|
|
81
|
+
const ok = codexVersion.status === 0;
|
|
82
|
+
return {
|
|
83
|
+
ok,
|
|
84
|
+
detail: ok
|
|
85
|
+
? String(codexVersion.stdout || "").trim()
|
|
86
|
+
: String(codexVersion.stderr || codexVersion.error || "无法执行 codex --version").trim(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectDoctorChecks(context) {
|
|
91
|
+
const codexVersion = probeCodexVersion();
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
name: "codex CLI",
|
|
95
|
+
ok: codexVersion.ok,
|
|
96
|
+
detail: codexVersion.detail,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "backlog.json",
|
|
100
|
+
ok: fileExists(context.backlogFile),
|
|
101
|
+
detail: context.backlogFile,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "policy.json",
|
|
105
|
+
ok: fileExists(context.policyFile),
|
|
106
|
+
detail: context.policyFile,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "verify.yaml",
|
|
110
|
+
ok: fileExists(context.repoVerifyFile),
|
|
111
|
+
detail: context.repoVerifyFile,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "project.json",
|
|
115
|
+
ok: fileExists(context.projectFile),
|
|
116
|
+
detail: context.projectFile,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "plugin manifest",
|
|
120
|
+
ok: fileExists(context.pluginManifestFile),
|
|
121
|
+
detail: context.pluginManifestFile,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "plugin skill",
|
|
125
|
+
ok: fileExists(context.skillFile),
|
|
126
|
+
detail: context.skillFile,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "install script",
|
|
130
|
+
ok: fileExists(context.installScriptFile),
|
|
131
|
+
detail: context.installScriptFile,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runDoctor(context) {
|
|
137
|
+
const checks = collectDoctorChecks(context);
|
|
138
|
+
|
|
139
|
+
for (const item of checks) {
|
|
140
|
+
console.log(`${item.ok ? "OK" : "FAIL"} ${item.name} ${item.detail}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (checks.every((item) => item.ok)) {
|
|
144
|
+
console.log("\nDoctor 结论:当前 HelloLoop bundle 与目标仓库已具备基本运行条件。");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (checks.some((item) => !item.ok)) {
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function runCli(argv) {
|
|
153
|
+
const { command, options } = parseArgs(argv);
|
|
154
|
+
|
|
155
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
156
|
+
printHelp();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const context = createContext({
|
|
161
|
+
repoRoot: options.repoRoot,
|
|
162
|
+
configDirName: options.configDirName,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (command === "install") {
|
|
166
|
+
const result = installPluginBundle({
|
|
167
|
+
bundleRoot: context.bundleRoot,
|
|
168
|
+
codexHome: options.codexHome,
|
|
169
|
+
force: options.force,
|
|
170
|
+
});
|
|
171
|
+
console.log(`HelloLoop 已安装到:${result.targetPluginRoot}`);
|
|
172
|
+
console.log(`Marketplace 已更新:${result.marketplaceFile}`);
|
|
173
|
+
console.log("");
|
|
174
|
+
console.log("下一步示例:");
|
|
175
|
+
console.log(`node ${path.join(result.targetPluginRoot, "scripts", "helloloop.mjs")} doctor --repo D:\\GitHub\\dev\\your-repo`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (command === "init") {
|
|
180
|
+
const created = scaffoldIfMissing(context);
|
|
181
|
+
if (!created.length) {
|
|
182
|
+
console.log("HelloLoop 配置已存在,无需初始化。");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.log([
|
|
186
|
+
"已初始化以下文件:",
|
|
187
|
+
...created.map((item) => `- ${path.relative(context.repoRoot, item).replaceAll("\\", "/")}`),
|
|
188
|
+
].join("\n"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (command === "doctor") {
|
|
193
|
+
await runDoctor(context);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (command === "status") {
|
|
198
|
+
console.log(renderStatusText(context, options));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (command === "next") {
|
|
203
|
+
const result = await runOnce(context, { ...options, dryRun: true });
|
|
204
|
+
if (!result.task) {
|
|
205
|
+
console.log("当前没有可执行任务。");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
console.log([
|
|
209
|
+
"下一任务预览",
|
|
210
|
+
"============",
|
|
211
|
+
`任务:${result.task.title}`,
|
|
212
|
+
`编号:${result.task.id}`,
|
|
213
|
+
`运行目录:${result.runDir}`,
|
|
214
|
+
"",
|
|
215
|
+
"验证命令:",
|
|
216
|
+
...result.verifyCommands.map((item) => `- ${item}`),
|
|
217
|
+
"",
|
|
218
|
+
"提示词:",
|
|
219
|
+
result.prompt,
|
|
220
|
+
].join("\n"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (command === "run-once") {
|
|
225
|
+
const result = await runOnce(context, options);
|
|
226
|
+
if (!result.ok) {
|
|
227
|
+
console.error(result.summary || "执行失败。");
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (options.dryRun) {
|
|
232
|
+
console.log(result.task
|
|
233
|
+
? `已生成干跑预览:${result.task.title}\n运行目录:${result.runDir}`
|
|
234
|
+
: "当前没有可执行任务。");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
console.log(result.task
|
|
238
|
+
? `完成任务:${result.task.title}\n运行目录:${result.runDir}`
|
|
239
|
+
: "没有可执行任务。");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (command === "run-loop") {
|
|
244
|
+
const results = await runLoop(context, options);
|
|
245
|
+
const failed = results.find((item) => !item.ok);
|
|
246
|
+
for (const item of results) {
|
|
247
|
+
if (!item.task) {
|
|
248
|
+
console.log("没有更多可执行任务。");
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
console.log(`${item.ok ? "成功" : "失败"}:${item.task.title}`);
|
|
252
|
+
}
|
|
253
|
+
if (failed) {
|
|
254
|
+
console.error(failed.summary || "连续执行中断。");
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw new Error(`未知命令:${command}`);
|
|
261
|
+
}
|
|
262
|
+
|
package/src/common.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureDir(dirPath) {
|
|
5
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function fileExists(filePath) {
|
|
9
|
+
return fs.existsSync(filePath);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readText(filePath) {
|
|
13
|
+
return fs.readFileSync(filePath, "utf8");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readTextIfExists(filePath, fallback = "") {
|
|
17
|
+
return fileExists(filePath) ? readText(filePath) : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readJson(filePath) {
|
|
21
|
+
return JSON.parse(readText(filePath));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeText(filePath, content) {
|
|
25
|
+
ensureDir(path.dirname(filePath));
|
|
26
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeJson(filePath, value) {
|
|
30
|
+
writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function nowIso() {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function timestampForFile(date = new Date()) {
|
|
38
|
+
return date.toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sanitizeId(value) {
|
|
42
|
+
return String(value || "")
|
|
43
|
+
.trim()
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9\u4e00-\u9fa5_-]+/g, "-")
|
|
46
|
+
.replace(/-+/g, "-")
|
|
47
|
+
.replace(/^-|-$/g, "") || "task";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function tailText(text, maxLines = 60) {
|
|
51
|
+
const normalized = String(text || "").replaceAll("\r\n", "\n");
|
|
52
|
+
const lines = normalized.split("\n");
|
|
53
|
+
return lines.slice(Math.max(0, lines.length - maxLines)).join("\n").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatList(items) {
|
|
57
|
+
return items.map((item) => `- ${item}`).join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveFrom(rootDir, ...segments) {
|
|
61
|
+
return path.join(rootDir, ...segments);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeRelative(rootDir, targetPath) {
|
|
65
|
+
return path.relative(rootDir, targetPath).replaceAll("\\", "/");
|
|
66
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
fileExists,
|
|
5
|
+
nowIso,
|
|
6
|
+
readJson,
|
|
7
|
+
readTextIfExists,
|
|
8
|
+
writeJson,
|
|
9
|
+
writeText,
|
|
10
|
+
} from "./common.mjs";
|
|
11
|
+
|
|
12
|
+
const defaultPolicy = {
|
|
13
|
+
version: 1,
|
|
14
|
+
updatedAt: nowIso(),
|
|
15
|
+
maxLoopTasks: 4,
|
|
16
|
+
maxTaskAttempts: 2,
|
|
17
|
+
maxTaskStrategies: 4,
|
|
18
|
+
stopOnFailure: false,
|
|
19
|
+
stopOnHighRisk: true,
|
|
20
|
+
codex: {
|
|
21
|
+
model: "",
|
|
22
|
+
executable: "",
|
|
23
|
+
sandbox: "workspace-write",
|
|
24
|
+
dangerouslyBypassSandbox: false,
|
|
25
|
+
jsonOutput: true,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const defaultPlanner = {
|
|
30
|
+
minTasks: 3,
|
|
31
|
+
maxTasks: 8,
|
|
32
|
+
roleInference: true,
|
|
33
|
+
workflowHints: [
|
|
34
|
+
"先识别每份文档的角色和可信度,再组织主线。",
|
|
35
|
+
"优先输出可逐步开发、测试、验收的任务。",
|
|
36
|
+
"避免把整个文档目录压成一个大任务。",
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function loadPolicy(context) {
|
|
41
|
+
const policy = {
|
|
42
|
+
...defaultPolicy,
|
|
43
|
+
...(fileExists(context.policyFile) ? readJson(context.policyFile) : {}),
|
|
44
|
+
};
|
|
45
|
+
policy.codex = {
|
|
46
|
+
...defaultPolicy.codex,
|
|
47
|
+
...(policy.codex || {}),
|
|
48
|
+
};
|
|
49
|
+
return policy;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadProjectConfig(context) {
|
|
53
|
+
if (!fileExists(context.projectFile)) {
|
|
54
|
+
return {
|
|
55
|
+
requiredDocs: [],
|
|
56
|
+
constraints: [],
|
|
57
|
+
planner: defaultPlanner,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = readJson(context.projectFile);
|
|
62
|
+
return {
|
|
63
|
+
requiredDocs: Array.isArray(config.requiredDocs) ? config.requiredDocs : [],
|
|
64
|
+
constraints: Array.isArray(config.constraints) ? config.constraints : [],
|
|
65
|
+
planner: {
|
|
66
|
+
...defaultPlanner,
|
|
67
|
+
...(config.planner || {}),
|
|
68
|
+
workflowHints: Array.isArray(config?.planner?.workflowHints)
|
|
69
|
+
? config.planner.workflowHints
|
|
70
|
+
: defaultPlanner.workflowHints,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function loadBacklog(context) {
|
|
76
|
+
const backlog = readJson(context.backlogFile);
|
|
77
|
+
if (!backlog || !Array.isArray(backlog.tasks)) {
|
|
78
|
+
throw new Error("HelloLoop backlog 无效:缺少 tasks 数组。");
|
|
79
|
+
}
|
|
80
|
+
return backlog;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveBacklog(context, backlog) {
|
|
84
|
+
writeJson(context.backlogFile, {
|
|
85
|
+
...backlog,
|
|
86
|
+
updatedAt: nowIso(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadVerifyCommands(context) {
|
|
91
|
+
const raw = readTextIfExists(context.repoVerifyFile, "");
|
|
92
|
+
return raw
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.map((line) => line.trimEnd())
|
|
95
|
+
.filter((line) => /^\s*-\s+/.test(line))
|
|
96
|
+
.map((line) => line.replace(/^\s*-\s+/, "").trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function loadRepoStateText(context) {
|
|
101
|
+
return readTextIfExists(context.repoStateFile, "").trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function writeStatus(context, status) {
|
|
105
|
+
writeJson(context.statusFile, {
|
|
106
|
+
...status,
|
|
107
|
+
updatedAt: nowIso(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function writeStateMarkdown(context, content) {
|
|
112
|
+
writeText(context.stateFile, `${content.trim()}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function scaffoldIfMissing(context) {
|
|
116
|
+
const files = [
|
|
117
|
+
["backlog.template.json", context.backlogFile],
|
|
118
|
+
["policy.template.json", context.policyFile],
|
|
119
|
+
["project.template.json", context.projectFile],
|
|
120
|
+
["status.template.json", context.statusFile],
|
|
121
|
+
["STATE.template.md", context.stateFile],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const created = [];
|
|
125
|
+
for (const [templateName, targetFile] of files) {
|
|
126
|
+
if (fileExists(targetFile)) continue;
|
|
127
|
+
const source = path.join(context.templatesDir, templateName);
|
|
128
|
+
const content = readTextIfExists(source, "");
|
|
129
|
+
writeText(targetFile, content);
|
|
130
|
+
created.push(targetFile);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const runsKeep = path.join(context.runsDir, ".gitkeep");
|
|
134
|
+
if (!fileExists(runsKeep)) {
|
|
135
|
+
writeText(runsKeep, "\n");
|
|
136
|
+
created.push(runsKeep);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return created;
|
|
140
|
+
}
|
|
141
|
+
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { fileExists, resolveFrom } from "./common.mjs";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const toolRoot = path.resolve(__dirname, "..");
|
|
9
|
+
const defaultConfigDirName = ".helloloop";
|
|
10
|
+
const legacyConfigDirName = ".helloagents/helloloop";
|
|
11
|
+
|
|
12
|
+
function resolveConfigDirName(repoRoot, explicitConfigDirName) {
|
|
13
|
+
if (explicitConfigDirName) {
|
|
14
|
+
return explicitConfigDirName;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultConfigRoot = resolveFrom(repoRoot, ...defaultConfigDirName.split("/"));
|
|
18
|
+
if (fileExists(defaultConfigRoot)) {
|
|
19
|
+
return defaultConfigDirName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const legacyConfigRoot = resolveFrom(repoRoot, ...legacyConfigDirName.split("/"));
|
|
23
|
+
if (fileExists(legacyConfigRoot)) {
|
|
24
|
+
return legacyConfigDirName;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return defaultConfigDirName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createContext(options = {}) {
|
|
31
|
+
const repoRoot = path.resolve(options.repoRoot || process.cwd());
|
|
32
|
+
const configDirName = resolveConfigDirName(repoRoot, options.configDirName);
|
|
33
|
+
const configRoot = resolveFrom(repoRoot, ...configDirName.split("/"));
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
repoRoot,
|
|
37
|
+
toolRoot,
|
|
38
|
+
bundleRoot: toolRoot,
|
|
39
|
+
templatesDir: resolveFrom(toolRoot, "templates"),
|
|
40
|
+
pluginManifestFile: resolveFrom(toolRoot, ".codex-plugin", "plugin.json"),
|
|
41
|
+
skillFile: resolveFrom(toolRoot, "skills", "helloloop", "SKILL.md"),
|
|
42
|
+
installScriptFile: resolveFrom(toolRoot, "scripts", "install-home-plugin.ps1"),
|
|
43
|
+
docsRoot: resolveFrom(toolRoot, "docs"),
|
|
44
|
+
configDirName,
|
|
45
|
+
configRoot,
|
|
46
|
+
backlogFile: resolveFrom(configRoot, "backlog.json"),
|
|
47
|
+
policyFile: resolveFrom(configRoot, "policy.json"),
|
|
48
|
+
projectFile: resolveFrom(configRoot, "project.json"),
|
|
49
|
+
statusFile: resolveFrom(configRoot, "status.json"),
|
|
50
|
+
stateFile: resolveFrom(configRoot, "STATE.md"),
|
|
51
|
+
runsDir: resolveFrom(configRoot, "runs"),
|
|
52
|
+
repoStateFile: resolveFrom(repoRoot, ".helloagents", "STATE.md"),
|
|
53
|
+
repoVerifyFile: resolveFrom(repoRoot, ".helloagents", "verify.yaml"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SUPPORTED_SUFFIX = new Set([
|
|
5
|
+
".md",
|
|
6
|
+
".markdown",
|
|
7
|
+
".mdx",
|
|
8
|
+
".txt",
|
|
9
|
+
".rst",
|
|
10
|
+
".adoc",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function walkDocs(directoryPath, files) {
|
|
14
|
+
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const absolute = path.join(directoryPath, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
walkDocs(absolute, files);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (SUPPORTED_SUFFIX.has(path.extname(entry.name).toLowerCase())) {
|
|
23
|
+
files.push(absolute);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function expandEntry(repoRoot, rawEntry) {
|
|
29
|
+
const absolute = path.isAbsolute(rawEntry)
|
|
30
|
+
? rawEntry
|
|
31
|
+
: path.resolve(repoRoot, rawEntry);
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(absolute)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stat = fs.statSync(absolute);
|
|
38
|
+
if (stat.isFile()) {
|
|
39
|
+
return [absolute];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!stat.isDirectory()) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const files = [];
|
|
47
|
+
walkDocs(absolute, files);
|
|
48
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizePath(repoRoot, absolutePath) {
|
|
52
|
+
const relative = path.relative(repoRoot, absolutePath);
|
|
53
|
+
if (!relative.startsWith("..")) {
|
|
54
|
+
return relative.replaceAll("\\", "/");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return absolutePath.replaceAll("\\", "/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function expandDocumentEntries(repoRoot, entries) {
|
|
61
|
+
const absoluteFiles = [];
|
|
62
|
+
for (const entry of entries || []) {
|
|
63
|
+
absoluteFiles.push(...expandEntry(repoRoot, entry));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
return absoluteFiles
|
|
68
|
+
.filter((absolutePath) => {
|
|
69
|
+
const key = absolutePath.toLowerCase();
|
|
70
|
+
if (seen.has(key)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
seen.add(key);
|
|
75
|
+
return true;
|
|
76
|
+
})
|
|
77
|
+
.map((absolutePath) => ({
|
|
78
|
+
absolutePath,
|
|
79
|
+
relativePath: normalizePath(repoRoot, absolutePath),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readDocumentPackets(repoRoot, entries, options = {}) {
|
|
84
|
+
const maxCharsPerFile = Number(options.maxCharsPerFile || 18000);
|
|
85
|
+
const maxTotalChars = Number(options.maxTotalChars || 90000);
|
|
86
|
+
let remaining = maxTotalChars;
|
|
87
|
+
|
|
88
|
+
const packets = [];
|
|
89
|
+
for (const file of expandDocumentEntries(repoRoot, entries)) {
|
|
90
|
+
if (remaining <= 0) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fullText = fs.readFileSync(file.absolutePath, "utf8");
|
|
95
|
+
const sliceLength = Math.max(0, Math.min(fullText.length, maxCharsPerFile, remaining));
|
|
96
|
+
packets.push({
|
|
97
|
+
path: file.relativePath,
|
|
98
|
+
content: fullText.slice(0, sliceLength),
|
|
99
|
+
truncated: sliceLength < fullText.length,
|
|
100
|
+
chars: sliceLength,
|
|
101
|
+
});
|
|
102
|
+
remaining -= sliceLength;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return packets;
|
|
106
|
+
}
|