@hoplogic/hopjit 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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/act-body-interpreter.d.ts +24 -0
  4. package/dist/act-body-interpreter.js +159 -0
  5. package/dist/act-body-parser.d.ts +10 -0
  6. package/dist/act-body-parser.js +577 -0
  7. package/dist/act-builtins.d.ts +12 -0
  8. package/dist/act-builtins.js +104 -0
  9. package/dist/ast-helpers.d.ts +26 -0
  10. package/dist/ast-helpers.js +58 -0
  11. package/dist/ast-runtime.d.ts +49 -0
  12. package/dist/ast-runtime.js +224 -0
  13. package/dist/ast-types.d.ts +260 -0
  14. package/dist/ast-types.js +4 -0
  15. package/dist/cli-types.d.ts +190 -0
  16. package/dist/cli-types.js +4 -0
  17. package/dist/cli.d.ts +26 -0
  18. package/dist/cli.js +623 -0
  19. package/dist/dispatcher.d.ts +92 -0
  20. package/dist/dispatcher.js +692 -0
  21. package/dist/doc-ref.d.ts +41 -0
  22. package/dist/doc-ref.js +214 -0
  23. package/dist/engine-traverse.d.ts +37 -0
  24. package/dist/engine-traverse.js +385 -0
  25. package/dist/engine.d.ts +141 -0
  26. package/dist/engine.js +1792 -0
  27. package/dist/errors.d.ts +47 -0
  28. package/dist/errors.js +34 -0
  29. package/dist/hoplog.d.ts +120 -0
  30. package/dist/hoplog.js +501 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +802 -0
  33. package/dist/persistence.d.ts +60 -0
  34. package/dist/persistence.js +208 -0
  35. package/dist/prompt.d.ts +130 -0
  36. package/dist/prompt.js +1014 -0
  37. package/dist/provider-types.d.ts +134 -0
  38. package/dist/provider-types.js +3 -0
  39. package/dist/runtime-types.d.ts +84 -0
  40. package/dist/runtime-types.js +4 -0
  41. package/dist/tools.d.ts +16 -0
  42. package/dist/tools.js +141 -0
  43. package/dist/validator.d.ts +23 -0
  44. package/dist/validator.js +959 -0
  45. package/driver/codex/AGENTS.md +54 -0
  46. package/driver/hopskill-build/SKILL.md +137 -0
  47. package/driver/hopskill-build/references/spec-skeleton.md +132 -0
  48. package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
  49. package/driver/hopskill-build/references/three-focus-rules.md +148 -0
  50. package/driver/hopspec-skill.md +187 -0
  51. package/driver/references/cli-discovery.md +36 -0
  52. package/driver/references/discovery.md +45 -0
  53. package/driver/references/driver-subagent.md +50 -0
  54. package/driver/references/parallel-worker.md +50 -0
  55. package/driver/references/step-execution-rules.md +47 -0
  56. package/package.json +52 -0
package/dist/cli.js ADDED
@@ -0,0 +1,623 @@
1
+ #!/usr/bin/env node
2
+ // @module: hop-cli ^anc-struct-hop-cli
3
+ import { Command } from 'commander';
4
+ import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, copyFileSync, cpSync } from 'node:fs';
5
+ import { resolve, join, isAbsolute, dirname } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { ExecutionEngine } from './engine.js';
8
+ import { parseSpec } from './parser.js';
9
+ import { validateSpec } from './validator.js';
10
+ import { readVars, readState, stateExists, flattenVars } from './persistence.js';
11
+ import { hasChildren, getChildren } from './ast-helpers.js';
12
+ // 在 spec 树中按 step_id 查找节点(join_parallel 用于定位 parallel 容器及其 children)
13
+ function findStepInSpec(stepId, steps) {
14
+ for (const step of steps) {
15
+ if (step.step_id === stepId)
16
+ return step;
17
+ if (hasChildren(step)) {
18
+ const found = findStepInSpec(stepId, getChildren(step));
19
+ if (found)
20
+ return found;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ const program = new Command();
26
+ program.name('hopjit').version('0.1.0').description('HopStep execution engine CLI'); // @a: anc-struct-hop-cli, anc-cli-dispatch
27
+ /** 把结果序列化为单行 JSON 写 stdout——CLI 对 Agent 消费方的结构化输出约定。见 [[hop-cli#^anc-cli-json-io]] */
28
+ export function output(data) {
29
+ process.stdout.write(JSON.stringify(data) + '\n');
30
+ }
31
+ /** 运行时错误(参数非法、IO 失败)写 stderr 并以非零 exit code 退出,与业务错误的 stdout JSON 分流。见 [[hop-cli#^anc-cli-json-io]] */
32
+ export function errorExit(message, code = 1) {
33
+ process.stderr.write(`Error: ${message}\n`);
34
+ process.exit(code);
35
+ }
36
+ // @file 解析根目录:并行 worker 的裸文件名(无 / 、非绝对)重定向到该 child 的 work_zone,
37
+ // 消除多 worker 共享 cwd 的固定名竞态。见 design/hop-cli.md ^anc-cli-parallel-file-isolation。
38
+ // @a: anc-cli-parallel-file-isolation
39
+ export function resolveFileArgBase(filePath, workZoneBase) {
40
+ // 仅对 worker 子实例的「裸文件名」重定向;绝对路径/含 / 的相对路径尊重 driver 显式意图
41
+ if (workZoneBase && !isAbsolute(filePath) && !filePath.includes('/')) {
42
+ return join(workZoneBase, filePath);
43
+ }
44
+ return resolve(filePath);
45
+ }
46
+ /** 解析 JSON 参数:`@file` 前缀从文件读取(走 worker work_zone 重定向),否则原样 JSON.parse。见 [[hop-cli#^anc-cli-file-arg-safety]] */
47
+ export function resolveParam(value, workZoneBase) {
48
+ if (!value)
49
+ return undefined;
50
+ if (value.startsWith('@')) {
51
+ const filePath = resolveFileArgBase(value.slice(1), workZoneBase);
52
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
53
+ }
54
+ return JSON.parse(value);
55
+ }
56
+ /** 提交越界校验:`--output @<path>` 的解析路径必须落在本执行单元 work_zone 内,越界(/tmp、
57
+ * 项目根、兄弟 child)→ 抛错拒绝提交。引擎唯一能真拦的点(复用模式 act 由 CC 执行、写入不过引擎,
58
+ * 只能提交时校验)。回归 ppt-html v4 串台:worker 写共享 /tmp 互相覆盖。见 [[hop-cli#^anc-cli-file-arg-safety]]。
59
+ * @a: anc-cli-file-arg-safety */
60
+ export function assertOutputInWorkZone(value, instanceDir, workZoneBase) {
61
+ if (!value || !value.startsWith('@'))
62
+ return; // 内联 JSON 不涉文件,免校验
63
+ const resolved = resolveFileArgBase(value.slice(1), workZoneBase);
64
+ const workZone = resolve(join(instanceDir, 'work_zone'));
65
+ // 允许 work_zone 目录本身及其子路径(含 worker 子实例的 <childDir>/work_zone,因 instanceDir 已是 childDir)
66
+ if (resolved !== workZone && !resolved.startsWith(workZone + '/')) {
67
+ errorExit(`WORK_ZONE_VIOLATION: --output 路径越界 '${resolved}' 不在本执行单元 work_zone '${workZone}' 内。`
68
+ + `请把 output 写到 work_zone(响应给出的 output_path),不要写 /tmp 或自选绝对路径——否则与兄弟 worker 串台。`);
69
+ }
70
+ }
71
+ /** 解析文本参数(如 --steps/--reason):`@file` 前缀读文件原文,否则返回原值——LLM 生成内容经 @file 防注入。见 [[hop-cli#^anc-cli-file-arg-safety]] */
72
+ export function resolveTextParam(value, workZoneBase) {
73
+ if (!value)
74
+ return undefined;
75
+ if (value.startsWith('@')) {
76
+ const filePath = resolveFileArgBase(value.slice(1), workZoneBase);
77
+ return readFileSync(filePath, 'utf-8');
78
+ }
79
+ return value;
80
+ }
81
+ // 判定 --instance 是否指向 parallel worker 子实例(路径含 /parallel/),
82
+ // 是则返回其 work_zone 工作区用于裸文件名重定向。// @a: anc-cli-parallel-file-isolation
83
+ export function workerWorkZone(instanceDir, instanceOpt) {
84
+ if (!instanceOpt || !instanceOpt.includes('/parallel/'))
85
+ return undefined;
86
+ return join(instanceDir, 'work_zone');
87
+ }
88
+ /** 解析目标实例目录:显式 --instance 直接定位,省略时取 state_dir 下最新(按 mtime)实例。见 [[hop-cli#^anc-cli-instance-resolve]] */
89
+ export function resolveInstance(stateDir, instanceId) {
90
+ if (instanceId) {
91
+ return join(stateDir, instanceId);
92
+ }
93
+ // 先按目录类型过滤(跳过文件),再取 mtime 排最新;单项 statSync 容错——
94
+ // 目录混入不可 stat 的项(权限/竞态删除)不应中断整个解析。
95
+ const entries = readdirSync(stateDir, { withFileTypes: true })
96
+ .filter(d => d.isDirectory())
97
+ .map(d => {
98
+ try {
99
+ return { name: d.name, mtimeMs: statSync(join(stateDir, d.name)).mtimeMs };
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ })
105
+ .filter((e) => e !== null)
106
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
107
+ if (entries.length === 0) {
108
+ throw new Error('No instances found in state directory');
109
+ }
110
+ return join(stateDir, entries[0].name);
111
+ }
112
+ /** 构造 CLI 默认 HostConfig(workspace=cwd + 默认沙箱),供独立模式命令实例化 Engine/Dispatcher。见 [[hop-cli#^anc-struct-hop-cli]] */
113
+ export function buildHostConfig() {
114
+ return {
115
+ workspace_dir: process.cwd(),
116
+ sandbox: {
117
+ filesystem: { workspace_dir: '.', read_access: { allowed: ['.'], denied: [], confirm_required: [] } },
118
+ network: { trusted_hosts: [] },
119
+ runtime: { available: [] },
120
+ },
121
+ api_key: process.env['HOPSTEP_ANTHROPIC_API_KEY'] ?? '',
122
+ };
123
+ }
124
+ /** context 精简档 resolve:env HOPJIT_CONTEXT_MODE > --context-mode flag > 默认 full。见 [[prompt-assembler#^anc-exec-context-mode]] */
125
+ export function resolveContextMode(flag) {
126
+ const v = (process.env['HOPJIT_CONTEXT_MODE'] ?? flag ?? '').toLowerCase();
127
+ return v === 'minimal' ? 'minimal' : 'full';
128
+ }
129
+ // === Commands ===
130
+ // 执行前合法性校验——只读,不创建实例。返回 error+warn 全集。
131
+ // 见 [[HopSpec V3配套HopJIT运行时能力#^anc-exec-validation]]
132
+ program.command('validate <spec>')
133
+ .description('Validate spec legality without creating an instance')
134
+ .action((specPath) => {
135
+ try {
136
+ const content = readFileSync(resolve(specPath), 'utf-8');
137
+ const { ast, errors: parseErrors } = parseSpec(content);
138
+ // 解析错误即 error 级,无法继续验证规则
139
+ if (parseErrors.length > 0) {
140
+ output({ status: 'error', errors: parseErrors, warnings: [] });
141
+ return;
142
+ }
143
+ const all = validateSpec(ast);
144
+ const errors = all.filter((e) => e.severity === 'error');
145
+ const warnings = all.filter((e) => e.severity === 'warn');
146
+ output({
147
+ status: errors.length > 0 ? 'error' : 'ok',
148
+ errors: errors,
149
+ warnings: warnings,
150
+ });
151
+ }
152
+ catch (err) {
153
+ errorExit(err instanceof Error ? err.message : String(err));
154
+ }
155
+ });
156
+ // list <dir>:扫目录列可执行 spec(无状态纯函数)。判定规则见 design/hop-cli.md ^anc-cli-list // @a: anc-cli-list
157
+ program.command('list <dir>')
158
+ .description('List executable HopSpec files in a directory (no state, no instance)')
159
+ .action((dir) => {
160
+ try {
161
+ const dirAbs = resolve(dir);
162
+ const entries = readdirSync(dirAbs).filter(f => f.endsWith('.md')).sort();
163
+ const specs = [];
164
+ for (const file of entries) {
165
+ const full = join(dirAbs, file);
166
+ let content;
167
+ try {
168
+ content = readFileSync(full, 'utf-8');
169
+ }
170
+ catch {
171
+ continue;
172
+ }
173
+ // 排除设计文档:@trace 头 / design/ 路径 / impl 段
174
+ if (content.includes('%% @trace') || full.includes('/design/') || content.includes('## impl '))
175
+ continue;
176
+ const { ast, errors: parseErrors } = parseSpec(content);
177
+ if (parseErrors.length > 0)
178
+ continue; // 解析失败跳过
179
+ const goal = (ast.header.goal ?? '').trim();
180
+ const hasSteps = (ast.steps?.length ?? 0) > 0;
181
+ if (!goal || !hasSteps)
182
+ continue; // 非可执行 spec
183
+ const goalFirst = goal.split(/[\n。.]/)[0].trim(); // Goal 首句
184
+ specs.push({ file, id: (ast.header.id ?? ast.header.title ?? '').trim(), goal: goalFirst });
185
+ }
186
+ output({ status: 'ok', dir, specs });
187
+ }
188
+ catch (err) {
189
+ errorExit(err instanceof Error ? err.message : String(err));
190
+ }
191
+ });
192
+ program.command('init <spec>')
193
+ .description('Parse spec and initialize execution instance')
194
+ .option('--params <json>', 'Spec inputs as JSON or @file')
195
+ .option('--parent <id>', 'Parent instance ID')
196
+ .option('--step <id>', 'Call step ID')
197
+ .option('--parallel-parent <id>', 'Parent instance ID (for parallel child subinstance)')
198
+ .option('--parallel-child <id>', 'Parallel child step ID (subinstance落 <parent>/parallel/<id>/)')
199
+ .option('--trace <id>', 'Trace ID')
200
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
201
+ .option('--log-dir <dir>', 'Log directory for .hoplog output')
202
+ .option('--log-level <level>', 'Log level: debug | info | warn', 'info')
203
+ .action((specPath, opts) => {
204
+ try {
205
+ const content = readFileSync(resolve(specPath), 'utf-8');
206
+ const hostConfig = buildHostConfig();
207
+ const params = resolveParam(opts.params);
208
+ // 子实例(复用模式)两类:call → <stateDir>/<parent>/calls/<step>/;parallel → <parent>/parallel/<child>/
209
+ const callMode = opts.parent && opts.step; // @a: anc-step-call
210
+ const isParallelChild = !!(opts.parallelParent && opts.parallelChild); // @a: anc-exec-parallel-subinstance
211
+ const childParent = callMode ? opts.parent : isParallelChild ? opts.parallelParent : undefined;
212
+ const childStep = callMode ? opts.step : isParallelChild ? opts.parallelChild : undefined;
213
+ const subdir = callMode ? 'calls' : 'parallel';
214
+ const effectiveStateDir = (callMode || isParallelChild)
215
+ ? join(opts.stateDir, childParent, subdir)
216
+ : opts.stateDir;
217
+ const engineOpts = {
218
+ stateDir: effectiveStateDir,
219
+ ...(params ? { params } : {}),
220
+ ...(opts.logDir ? { logDir: opts.logDir } : {}),
221
+ ...(opts.logLevel ? { logLevel: opts.logLevel } : {}),
222
+ ...((callMode || isParallelChild) ? { parentInstanceId: childParent, callStepId: childStep } : {}),
223
+ ...(isParallelChild ? { canFanout: false, subtreeRoot: opts.parallelChild } : {}),
224
+ ...(opts.trace ? { traceId: opts.trace } : {}),
225
+ };
226
+ const engine = new ExecutionEngine();
227
+ const result = engine.initExecution(content, hostConfig, engineOpts);
228
+ output(result);
229
+ }
230
+ catch (err) {
231
+ errorExit(err instanceof Error ? err.message : String(err));
232
+ }
233
+ });
234
+ // run = init + 推进到首个 caller 介入点(引擎 completeAndAdvance 节奏)。// @a: anc-cli-dispatch, anc-exec-parallel-subinstance
235
+ // 支持 --parallel-parent/--parallel-child 创建 worker 子实例并推进(worker 一条命令完成 init+advance)。
236
+ program.command('run <spec>')
237
+ .description('Init and advance to first caller-action point or terminal (combines init + advance)')
238
+ .option('--params <json>', 'Spec inputs as JSON or @file')
239
+ .option('--parallel-parent <id>', 'Parent instance ID (for parallel worker subinstance)')
240
+ .option('--parallel-child <id>', 'Parallel child step ID (worker scope)')
241
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
242
+ .option('--log-dir <dir>', 'Log directory for .hoplog output')
243
+ .option('--log-level <level>', 'Log level: debug | info | warn', 'info')
244
+ .option('--context-mode <mode>', 'Context verbosity: full (default) | minimal (see ^anc-exec-context-mode)')
245
+ .action(async (specPath, opts) => {
246
+ try {
247
+ const content = readFileSync(resolve(specPath), 'utf-8');
248
+ const hostConfig = buildHostConfig();
249
+ const isWorker = !!(opts.parallelParent && opts.parallelChild);
250
+ const effectiveStateDir = isWorker
251
+ ? join(opts.stateDir, opts.parallelParent, 'parallel')
252
+ : opts.stateDir;
253
+ // worker 启动入口也走裸 @file 隔离:--params @裸名 重定向到本 child work_zone,
254
+ // 与 submit_and_fetch_next 同规则,消除共享 cwd 竞态。// @a: anc-cli-parallel-file-isolation
255
+ const runWorkZoneBase = isWorker
256
+ ? join(opts.stateDir, opts.parallelParent, 'parallel', opts.parallelChild, 'work_zone')
257
+ : undefined;
258
+ const params = resolveParam(opts.params, runWorkZoneBase);
259
+ // for-each worker 的 itemVar 经引擎单一权威、driver 透传的 --params 注入——
260
+ // worker 不翻父实例自播种(破隔离+权威倒置,已撤;缺 itemVar 由 engine init 硬校验报错)。
261
+ // 见 design/parallel-execution.md §9e ^anc-exec-parallel-foreach-worker。// @a: anc-exec-parallel-foreach-worker
262
+ const engineOpts = {
263
+ stateDir: resolve(effectiveStateDir),
264
+ canFanout: !isWorker,
265
+ specPath: resolve(specPath),
266
+ cliAbsPath: resolve(fileURLToPath(import.meta.url)),
267
+ ...(isWorker ? { subtreeRoot: opts.parallelChild, parentInstanceId: opts.parallelParent, callStepId: opts.parallelChild, traceId: opts.parallelParent } : {}), // @a: anc-obs-trace-inherit
268
+ ...(params ? { params } : {}),
269
+ ...(opts.logDir ? { logDir: resolve(opts.logDir) } : {}),
270
+ ...(opts.logLevel ? { logLevel: opts.logLevel } : {}),
271
+ contextMode: resolveContextMode(opts.contextMode), // env > flag > full。@a: anc-exec-context-mode
272
+ };
273
+ const engine = new ExecutionEngine();
274
+ const initResult = engine.initExecution(content, hostConfig, engineOpts);
275
+ if (initResult.status !== 'ok') {
276
+ output(initResult);
277
+ return;
278
+ }
279
+ const result = await engine.advanceToCaller();
280
+ output(result);
281
+ }
282
+ catch (err) {
283
+ errorExit(err instanceof Error ? err.message : String(err));
284
+ }
285
+ });
286
+ // submit_and_fetch_next:driver 主循环唯一应答命令——交活 + 领取下一指令(一次原子往返)。
287
+ // 提交内容由互斥参数区分;回写成功则 completeAndAdvance 推进返回 NextResponse,出错原样返回不推进。
288
+ // 节奏归引擎,见 design/exec-engine.md ^anc-exec-advance-to-caller。// @a: anc-cli-dispatch
289
+ program.command('submit_and_fetch_next <step-id>')
290
+ .description('Submit step result and fetch next caller-action point (driver main loop)')
291
+ .option('--output <json>', 'Step result (reason/check/act) as JSON or @file')
292
+ .option('--answer <json>', 'Confirm decision (HITL) as JSON or @file')
293
+ .option('--child-instance <id>', 'Call child instance ID (maps callee outputs back)')
294
+ .option('--failure <text>', 'Step failure reason')
295
+ .option('--branch <case-id>', 'Branch case selection')
296
+ .option('--replan <md>', 'Replanned children markdown or @file (for adaptive_needed)')
297
+ .option('--reason <text>', 'Optional reason (for branch selection audit)')
298
+ .option('--instance <id>', 'Instance ID')
299
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
300
+ .action(async (stepId, opts) => {
301
+ try {
302
+ // 互斥校验:一次只提交一种
303
+ const modes = ['output', 'answer', 'childInstance', 'failure', 'branch', 'replan']
304
+ .filter(k => opts[k] !== undefined);
305
+ if (modes.length > 1) {
306
+ throw new Error(`INVALID_STATE: 互斥参数只能给一个,收到 [${modes.join(', ')}]`);
307
+ }
308
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
309
+ const engine = ExecutionEngine.load(instanceDir);
310
+ // can_fanout 已从 StateFile 恢复(顶层 true / worker false),无需显式设
311
+ // parallel worker 裸 @file 重定向到本 child work_zone,消除共享 cwd 竞态。
312
+ // @a: anc-cli-parallel-file-isolation
313
+ const workZoneBase = workerWorkZone(instanceDir, opts.instance);
314
+ // 按参数路由到回写方法 → 成功则 completeAndAdvance 推进;出错原样返回不推进。
315
+ let writeResult;
316
+ if (opts.childInstance) { // call 回填
317
+ const childDir = join(instanceDir, 'calls', opts.childInstance);
318
+ const childVars = flattenVars(readVars(childDir));
319
+ writeResult = engine.completeCallStep(stepId, childVars);
320
+ }
321
+ else if (opts.failure !== undefined) { // 步骤失败
322
+ const reason = resolveTextParam(opts.failure, workZoneBase) ?? opts.failure;
323
+ writeResult = engine.failStep(stepId, reason);
324
+ }
325
+ else if (opts.branch !== undefined) { // 分支选择
326
+ writeResult = engine.selectBranch(stepId, opts.branch, opts.reason);
327
+ }
328
+ else if (opts.replan !== undefined) { // 重规划 children
329
+ const stepsMd = resolveTextParam(opts.replan, workZoneBase) ?? opts.replan;
330
+ writeResult = engine.submitReplan(stepId, stepsMd);
331
+ }
332
+ else { // 普通输出 / confirm answer
333
+ // output / answer 互斥(前面 modes 校验保证),取非空的那个解析
334
+ // 提交越界校验:--output @file 的路径必须在本执行单元 work_zone 内,越界拒绝(防串台)。
335
+ // @a: anc-cli-file-arg-safety
336
+ if (opts.output !== undefined)
337
+ assertOutputInWorkZone(opts.output, instanceDir, workZoneBase);
338
+ const raw = opts.output ?? opts.answer;
339
+ const outputs = raw ? resolveParam(raw, workZoneBase) : undefined;
340
+ writeResult = engine.completeStep(stepId, outputs);
341
+ }
342
+ if (writeResult.status !== 'ok') {
343
+ output(writeResult);
344
+ return;
345
+ } // 出错不推进
346
+ const next = await engine.advanceToCaller();
347
+ output(next);
348
+ }
349
+ catch (err) {
350
+ errorExit(err instanceof Error ? err.message : String(err));
351
+ }
352
+ });
353
+ // join_parallel:N 个 worker 子实例全部完成后,单进程收集各子目录输出 merge 回父 scope,推进到下一步。
354
+ // 规避 N 进程并发写父 state.json/vars.json 的丢更新(决策9)。// @a: anc-exec-parallel-join-merge
355
+ program.command('join_parallel <parallel-step-id>')
356
+ .description('Collect parallel child subinstance outputs, merge into parent scope, advance (single-process join)')
357
+ .option('--instance <id>', 'Instance ID')
358
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
359
+ .action(async (parallelStepId, opts) => {
360
+ try {
361
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
362
+ const engine = ExecutionEngine.load(instanceDir);
363
+ // can_fanout 已从 StateFile 恢复(join 在顶层实例调,can_fanout=true)
364
+ const spec = engine.getSpec();
365
+ const parallel = spec ? findStepInSpec(parallelStepId, spec.steps ?? []) : null;
366
+ if (!parallel || parallel.step_type !== 'parallel') {
367
+ errorExit(`INVALID_STEP_ID: '${parallelStepId}' is not a parallel step`);
368
+ return;
369
+ }
370
+ // 收集各 child 子实例结果:读 parallel/<child_id>/ 的 vars + state(判定 failed)。
371
+ // for-each parallel 的 spec 只有模板 child({P}.1),fan-out 落 N 个合成子实例目录——
372
+ // 必须扫 parallel/ 目录枚举实际子实例,不能用 getChildren(只有模板,漏 N-1 个)。
373
+ // 见 design/parallel-execution.md §9c' ^anc-exec-parallel-foreach-join-collect。// @a: anc-exec-parallel-foreach-join-collect
374
+ const parallelDir = join(instanceDir, 'parallel');
375
+ const scanForeachChildren = () => {
376
+ try {
377
+ return readdirSync(parallelDir, { withFileTypes: true }) // forEach:枚举实际合成子实例目录
378
+ .filter(e => e.isDirectory() && e.name.startsWith(`${parallelStepId}.`))
379
+ .map(e => e.name);
380
+ }
381
+ catch {
382
+ return [];
383
+ } // parallel/ 目录不存在(未 fan-out 即 join)→ 空集
384
+ };
385
+ const childIds = parallel.forEach
386
+ ? scanForeachChildren()
387
+ : getChildren(parallel).map(c => c.step_id); // 静态:spec children 即真实 ID
388
+ // join 前置不变量(见 design/exec-engine.md ^anc-exec-parallel-join-preconditions):
389
+ // ① 完整性+终态校验:所有期望的 fan-out child 必须有终态子实例(漏建/仍 running → 拒绝 join),
390
+ // 防 driver 提前 join 漏 worker 产生残缺 merge;
391
+ // ② 格式校验:readState/readVars 内置 format_version + 关键字段校验(CORRUPT_STATE_FILE);
392
+ // ③ 单 child 读失败隔离:try/catch 包每个 child,损坏的标 failed 不连累其余。
393
+ // @a: anc-exec-parallel-join-preconditions
394
+ // for-each 期望 child 数 = listVar 长度;每个合成 ID {P}.k 都必须有终态子实例。
395
+ // 未启动的 worker 根本没有子目录,单靠"扫已存在目录"会漏掉它们——故按期望集逐一核验。
396
+ const expectedIds = (() => {
397
+ if (!parallel.forEach)
398
+ return childIds; // 静态:spec children 即期望集
399
+ const listVar = parallel.forEach.listVar;
400
+ const listVal = flattenVars(readVars(instanceDir))[listVar];
401
+ // listVar 非数组必须响亮报错,不静默算 0——否则"listVar 坏了"(deflate @file/$file 指针、
402
+ // 上游谎报)会伪装成"0 个 child"、产出空 join,故障漂离源头。见 design ^anc-exec-parallel-join-preconditions。
403
+ if (!Array.isArray(listVal)) {
404
+ errorExit(`INVALID_STATE: for-each listVar '${listVar}' 不是数组(实际 ${typeof listVal}${typeof listVal === 'string' ? `: ${JSON.stringify(String(listVal).slice(0, 60))}` : ''}),无法算期望 child 数——上游该输出真数组,勿传 @file 指针字符串`);
405
+ return [];
406
+ }
407
+ return Array.from({ length: listVal.length }, (_, i) => `${parallelStepId}.${i + 1}`);
408
+ })();
409
+ const childResults = {};
410
+ let blockedChild = null; // ① 漏建/未终态违例(收集后统一 errorExit)
411
+ for (const childId of expectedIds) {
412
+ const childDir = join(parallelDir, childId);
413
+ if (!stateExists(childDir)) {
414
+ // 子实例不存在:for-each 必须全部 worker 跑完——漏建即未完成,拒绝;
415
+ // 静态 parallel 的 child 可能因含 confirm 退串行而无子实例目录,跳过(其状态在父 state 中串行推进)。
416
+ if (parallel.forEach) {
417
+ blockedChild = childId;
418
+ break;
419
+ }
420
+ continue;
421
+ }
422
+ try {
423
+ const childState = readState(childDir);
424
+ // ① 终态校验:worker 仍在跑(含 running 步骤)→ 拒绝整个 join(响亮失败,非脏 merge)
425
+ if (Object.values(childState.step_states).some(s => s === 'running')) {
426
+ blockedChild = childId;
427
+ break;
428
+ }
429
+ const childVars = flattenVars(readVars(childDir));
430
+ // 子实例含 failed 顶层步骤即视为失败(沿用底层 parallel child fail 语义)
431
+ const failed = Object.values(childState.step_states).some(s => s === 'failed');
432
+ // log = 该 child 子 hoplog run 目录(join 块记录子日志定位线索,见 ^anc-obs-parallel-dispatch)
433
+ childResults[childId] = { vars: childVars, failed, log: childState.hoplog_run_dir };
434
+ }
435
+ catch {
436
+ // ③ 单 child 文件损坏/读失败(CORRUPT_STATE_FILE 等)→ 标 failed(聚合写 None),不连累其余
437
+ childResults[childId] = { vars: {}, failed: true };
438
+ }
439
+ }
440
+ if (blockedChild) {
441
+ errorExit(`INVALID_STATE: parallel child '${blockedChild}' 未终态或子实例缺失,不能 join——请等所有 worker 返回后再 join`);
442
+ return;
443
+ }
444
+ const writeResult = engine.joinParallel(parallelStepId, childResults);
445
+ if (writeResult.status !== 'ok') {
446
+ output(writeResult);
447
+ return;
448
+ }
449
+ const next = await engine.advanceToCaller();
450
+ output(next);
451
+ }
452
+ catch (err) {
453
+ errorExit(err instanceof Error ? err.message : String(err));
454
+ }
455
+ });
456
+ // fanout-plan:parallel_ready 后主 agent 调一次,拿初始批 worker 启动命令。CLI 无状态算调度、原子标 dispatched。
457
+ // 见 design/parallel-execution.md ^anc-exec-parallel-fanout-advisor。// @a: anc-exec-parallel-fanout-advisor
458
+ program.command('fanout-plan <parallel-step-id>')
459
+ .description('fan-out 调度顾问:返回初始批 worker 的完整启动命令(主 agent 起 subagent 执行,不自己拼命令)')
460
+ .option('--instance <id>', 'Instance ID')
461
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
462
+ .action((parallelStepId, opts) => {
463
+ try {
464
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
465
+ const engine = ExecutionEngine.load(instanceDir);
466
+ output(engine.fanoutSchedule(parallelStepId));
467
+ }
468
+ catch (err) {
469
+ errorExit(err instanceof Error ? err.message : String(err));
470
+ }
471
+ });
472
+ // fanout-next:主 agent 每收一个 worker 完成通知即调,拿下一动作(补位 launch / join / wait)。
473
+ // --done/--status 标识哪个 child 完成及成败;计算本身 level-triggered 读全量子实例状态复原窗口。
474
+ // 见 design/parallel-execution.md ^anc-exec-parallel-fanout-advisor / ^anc-exec-parallel-fanout-concurrency。// @a: anc-exec-parallel-fanout-advisor
475
+ program.command('fanout-next <parallel-step-id>')
476
+ .description('fan-out 调度顾问:worker 完成后返回下一动作(launch 补位 / join 收敛 / wait 等待)')
477
+ .option('--done <child-step-id>', 'Completed child step ID (from notification label)')
478
+ .option('--status <status>', 'Completed child status: completed | failed', 'completed')
479
+ .option('--instance <id>', 'Instance ID')
480
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
481
+ .action((parallelStepId, opts) => {
482
+ try {
483
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
484
+ const engine = ExecutionEngine.load(instanceDir);
485
+ output(engine.fanoutSchedule(parallelStepId));
486
+ }
487
+ catch (err) {
488
+ errorExit(err instanceof Error ? err.message : String(err));
489
+ }
490
+ });
491
+ // debug_step:调试用——纯推进一步、不消化纯计算 body、不自动 advance。正常驱动用 run + submit_and_fetch_next。
492
+ program.command('debug_step')
493
+ .description('[DEBUG] Advance exactly one step (no body consumption, no auto-advance). Normal driving uses run + submit_and_fetch_next')
494
+ .option('--instance <id>', 'Instance ID')
495
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
496
+ .action((opts) => {
497
+ try {
498
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
499
+ const engine = ExecutionEngine.load(instanceDir);
500
+ const result = engine.nextStep();
501
+ output(result);
502
+ }
503
+ catch (err) {
504
+ errorExit(err instanceof Error ? err.message : String(err));
505
+ }
506
+ });
507
+ program.command('status')
508
+ .description('Get execution status')
509
+ .option('--instance <id>', 'Instance ID')
510
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
511
+ .action((opts) => {
512
+ try {
513
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
514
+ const engine = ExecutionEngine.load(instanceDir);
515
+ const result = engine.getStatus();
516
+ output(result);
517
+ }
518
+ catch (err) {
519
+ errorExit(err instanceof Error ? err.message : String(err));
520
+ }
521
+ });
522
+ program.command('vars')
523
+ .description('Get all variable values')
524
+ .option('--instance <id>', 'Instance ID')
525
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
526
+ .action((opts) => {
527
+ try {
528
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
529
+ const engine = ExecutionEngine.load(instanceDir);
530
+ const result = engine.getVars();
531
+ output(result);
532
+ }
533
+ catch (err) {
534
+ errorExit(err instanceof Error ? err.message : String(err));
535
+ }
536
+ });
537
+ program.command('resume')
538
+ .description('Resume execution from crash or pause (recover + advance to next caller-action point)')
539
+ .option('--instance <id>', 'Instance ID')
540
+ .option('--state-dir <dir>', 'State directory', '.hopstate')
541
+ .action(async (opts) => {
542
+ try {
543
+ const instanceDir = resolveInstance(opts.stateDir, opts.instance);
544
+ const engine = ExecutionEngine.recover(instanceDir); // 崩溃恢复:重置悬空 running,落盘
545
+ const result = await engine.advanceToCaller(); // 推进到下一个 caller 介入点
546
+ output(result);
547
+ }
548
+ catch (err) {
549
+ errorExit(err instanceof Error ? err.message : String(err));
550
+ }
551
+ });
552
+ // install-skill:把包内 driver/ 的 skill 源展开到 .claude/skills/(CC)或指定目录。
553
+ // 纯文件拷贝、不涉引擎状态。包根用 import.meta.url 定位(不依赖 cwd)。见 design/hop-cli.md ^anc-cli-install-skill。
554
+ // @a: anc-cli-install-skill
555
+ program.command('install-skill')
556
+ .description('Install hopspec driver skills into .claude/skills/ (or --dir) from the package driver/ sources')
557
+ .option('--dir <target>', 'Target skills directory', '.claude/skills')
558
+ .option('--carrier <carrier>', 'Driver carrier: cc | codex', 'cc')
559
+ .option('--force', 'Overwrite existing files')
560
+ .action((opts) => {
561
+ try {
562
+ // 包根 = cli.js(dist/cli.js)的 dist 的上一级
563
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
564
+ const driverDir = join(pkgRoot, 'driver');
565
+ if (!existsSync(driverDir)) {
566
+ errorExit(`INSTALL_SKILL_ERROR: 包内未找到 driver/(${driverDir})——确认包完整安装`);
567
+ return;
568
+ }
569
+ const target = isAbsolute(opts.dir) ? opts.dir : resolve(process.cwd(), opts.dir);
570
+ const installed = [];
571
+ const force = opts.force === true;
572
+ // 拷贝 helper:目标存在且非 force → 跳过并记录
573
+ const copyFile = (src, dst) => {
574
+ if (existsSync(dst) && !force)
575
+ return false;
576
+ mkdirSync(dirname(dst), { recursive: true });
577
+ copyFileSync(src, dst);
578
+ return true;
579
+ };
580
+ const copyDir = (src, dst) => {
581
+ if (existsSync(dst) && !force)
582
+ return false;
583
+ mkdirSync(dirname(dst), { recursive: true });
584
+ cpSync(src, dst, { recursive: true });
585
+ return true;
586
+ };
587
+ if (opts.carrier === 'codex') {
588
+ // Codex 载体:AGENTS.md + references
589
+ const agents = join(driverDir, 'codex', 'AGENTS.md');
590
+ if (copyFile(agents, join(target, 'AGENTS.md')))
591
+ installed.push(join(target, 'AGENTS.md'));
592
+ if (copyDir(join(driverDir, 'references'), join(target, 'references')))
593
+ installed.push(join(target, 'references/'));
594
+ }
595
+ else {
596
+ // CC 载体(默认):hopspec-skill.md → hopspec/SKILL.md + references + hopskill-build/
597
+ if (copyFile(join(driverDir, 'hopspec-skill.md'), join(target, 'hopspec', 'SKILL.md')))
598
+ installed.push(join(target, 'hopspec/SKILL.md'));
599
+ if (copyDir(join(driverDir, 'references'), join(target, 'hopspec', 'references')))
600
+ installed.push(join(target, 'hopspec/references/'));
601
+ if (copyDir(join(driverDir, 'hopskill-build'), join(target, 'hopskill-build')))
602
+ installed.push(join(target, 'hopskill-build/'));
603
+ }
604
+ const skipped = installed.length === 0;
605
+ output({
606
+ status: 'ok',
607
+ carrier: opts.carrier,
608
+ target,
609
+ installed,
610
+ ...(skipped ? { note: '无文件写入(目标已存在,用 --force 覆盖)' } : {}),
611
+ });
612
+ }
613
+ catch (err) {
614
+ errorExit(err instanceof Error ? err.message : String(err));
615
+ }
616
+ });
617
+ export { program };
618
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
619
+ if (isMain) {
620
+ program.parseAsync(process.argv).catch(err => {
621
+ errorExit(err instanceof Error ? err.message : String(err));
622
+ });
623
+ }