@fitlab-ai/agent-infra 0.7.7 → 0.8.1

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 (142) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +42 -15
  4. package/dist/bin/cli.js +46 -16
  5. package/dist/lib/decide.js +110 -0
  6. package/dist/lib/run/host.js +39 -0
  7. package/dist/lib/run/index.js +193 -0
  8. package/dist/lib/run/prompt.js +2 -0
  9. package/dist/lib/run/skills.js +29 -0
  10. package/dist/lib/run/tui.js +35 -0
  11. package/dist/lib/sandbox/capture.js +158 -0
  12. package/dist/lib/sandbox/commands/create.js +58 -9
  13. package/dist/lib/sandbox/commands/enter.js +7 -3
  14. package/dist/lib/sandbox/credentials.js +42 -3
  15. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  16. package/dist/lib/server/adapters/_contract.js +9 -0
  17. package/dist/lib/server/adapters/feishu/index.js +40 -0
  18. package/dist/lib/server/adapters/feishu/transport.js +95 -0
  19. package/dist/lib/server/auth.js +19 -0
  20. package/dist/lib/server/config.js +183 -0
  21. package/dist/lib/server/daemon.js +96 -0
  22. package/dist/lib/server/index.js +47 -0
  23. package/dist/lib/server/logger.js +34 -0
  24. package/dist/lib/server/plugin-loader.js +73 -0
  25. package/dist/lib/server/process-control.js +200 -0
  26. package/dist/lib/server/protocol.js +69 -0
  27. package/dist/lib/server/redact.js +12 -0
  28. package/dist/lib/server/runner.js +75 -0
  29. package/dist/lib/server/streamer.js +44 -0
  30. package/dist/lib/task/commands/decisions.js +258 -0
  31. package/dist/lib/task/commands/log.js +38 -41
  32. package/dist/lib/task/commands/status.js +144 -45
  33. package/dist/lib/task/index.js +9 -0
  34. package/dist/lib/task/ledger.js +66 -0
  35. package/dist/lib/task/sections.js +30 -1
  36. package/lib/decide.ts +124 -0
  37. package/lib/run/host.ts +47 -0
  38. package/lib/run/index.ts +237 -0
  39. package/lib/run/prompt.ts +1 -0
  40. package/lib/run/skills.ts +34 -0
  41. package/lib/run/tui.ts +40 -0
  42. package/lib/sandbox/capture.ts +215 -0
  43. package/lib/sandbox/commands/create.ts +64 -12
  44. package/lib/sandbox/commands/enter.ts +8 -2
  45. package/lib/sandbox/credentials.ts +57 -3
  46. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  47. package/lib/server/adapters/_contract.ts +42 -0
  48. package/lib/server/adapters/feishu/index.ts +47 -0
  49. package/lib/server/adapters/feishu/transport.ts +128 -0
  50. package/lib/server/auth.ts +28 -0
  51. package/lib/server/config.ts +228 -0
  52. package/lib/server/daemon.ts +110 -0
  53. package/lib/server/index.ts +50 -0
  54. package/lib/server/logger.ts +48 -0
  55. package/lib/server/plugin-loader.ts +84 -0
  56. package/lib/server/process-control.ts +225 -0
  57. package/lib/server/protocol.ts +79 -0
  58. package/lib/server/redact.ts +12 -0
  59. package/lib/server/runner.ts +95 -0
  60. package/lib/server/server.schema.json +127 -0
  61. package/lib/server/streamer.ts +57 -0
  62. package/lib/task/commands/decisions.ts +272 -0
  63. package/lib/task/commands/log.ts +38 -39
  64. package/lib/task/commands/status.ts +200 -58
  65. package/lib/task/index.ts +9 -0
  66. package/lib/task/ledger.ts +73 -0
  67. package/lib/task/sections.ts +30 -1
  68. package/package.json +8 -3
  69. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  70. package/templates/.agents/rules/issue-fields.github.en.md +1 -1
  71. package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
  72. package/templates/.agents/rules/next-step-output.en.md +5 -1
  73. package/templates/.agents/rules/next-step-output.zh-CN.md +5 -1
  74. package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
  75. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
  76. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  77. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  78. package/templates/.agents/rules/review-handshake.en.md +5 -1
  79. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
  80. package/templates/.agents/rules/task-management.en.md +2 -2
  81. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  82. package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
  83. package/templates/.agents/scripts/validate-artifact.js +8 -0
  84. package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -3
  85. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
  86. package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
  87. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
  88. package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
  89. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -3
  90. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  91. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  92. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  93. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  94. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
  95. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
  96. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
  97. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
  98. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  99. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  100. package/templates/.agents/skills/create-task/SKILL.en.md +1 -3
  101. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
  102. package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
  103. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
  104. package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
  105. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
  106. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
  107. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
  108. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
  109. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
  110. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  111. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  112. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  113. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  114. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  115. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  116. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  117. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  118. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  119. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  120. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  121. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  122. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  123. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  124. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  125. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  126. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  127. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  128. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  129. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  130. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  131. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  132. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  134. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  135. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  136. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  137. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  138. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  139. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
  140. package/templates/.agents/templates/task.en.md +3 -3
  141. package/templates/.agents/templates/task.zh-CN.md +3 -3
  142. package/templates/.github/workflows/metadata-sync.yml +0 -18
@@ -1,23 +1,26 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { execFileSync } from 'node:child_process';
4
+ import { commandForEngine } from "../../sandbox/shell.js";
3
5
  import { resolveTaskRef } from "../resolve-ref.js";
4
6
  import { enumerateArtifacts } from "../artifacts.js";
5
7
  import { parseTaskFrontmatter, extractTitle } from "../frontmatter.js";
6
8
  import { loadShortIdByTaskId } from "../short-id.js";
9
+ import { parseActivityLog, pairEntries } from "./log.js";
7
10
  const USAGE = `Usage: ai task status <N | #N | TASK-id>
8
11
 
9
- Prints an aggregated "health check" view for a task: header, metadata, an
10
- artifacts summary, git branch state, and best-effort GitHub issue/PR status.
12
+ Prints an aggregated "health check" view for a task: header, metadata,
13
+ artifacts, workflow/runtime execution state, and git branch state.
11
14
  <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
12
15
 
13
- Git and Platform rows are best-effort: a failed git/gh call degrades that row to
14
- '-' without failing the command.
16
+ Git rows are best-effort: a failed git call degrades that row to '-' without
17
+ failing the command.
15
18
  `;
16
19
  const DASH = '-';
17
20
  function makeRunner(cwd) {
18
21
  return (file, args) => execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
19
22
  }
20
- // Run `run` and swallow any failure into null, so a single failing git/gh call
23
+ // Run `run` and swallow any failure into null, so a single failing git call
21
24
  // degrades only its own field instead of aborting the whole view.
22
25
  function tryRun(run, file, args) {
23
26
  try {
@@ -37,9 +40,7 @@ const METADATA_KEYS = [
37
40
  'branch',
38
41
  'assigned_to',
39
42
  'created_at',
40
- 'updated_at',
41
- 'issue_number',
42
- 'pr_status'
43
+ 'updated_at'
43
44
  ];
44
45
  function collectMetadata(fm) {
45
46
  return METADATA_KEYS.map((key) => [key, fm[key] ? fm[key] : DASH]);
@@ -136,39 +137,123 @@ function collectGit(frontmatterBranch, run) {
136
137
  }
137
138
  return { current, frontmatter, match, exists, uncommitted, aheadBehind };
138
139
  }
139
- function collectPlatform(fm, run) {
140
- let issue = DASH;
141
- if (fm.issue_number && /^\d+$/.test(fm.issue_number)) {
142
- const out = tryRun(run, 'gh', ['issue', 'view', fm.issue_number, '--json', 'state,labels']);
143
- if (out !== null) {
144
- try {
145
- const data = JSON.parse(out);
146
- const labels = Array.isArray(data.labels)
147
- ? data.labels.map((label) => label.name).join(', ')
148
- : '';
149
- issue = labels ? `${data.state} [${labels}]` : `${data.state}`;
150
- }
151
- catch {
152
- issue = DASH;
140
+ const STALE_MS = 60 * 60 * 1000;
141
+ function parseActivityTime(value) {
142
+ const epoch = Date.parse(value.replace(' ', 'T'));
143
+ return Number.isFinite(epoch) ? epoch : Number.NaN;
144
+ }
145
+ function collectWorkflow(content, now = new Date()) {
146
+ const parsed = parseActivityLog(content);
147
+ if (!parsed.sectionFound || parsed.entries.length === 0) {
148
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
149
+ }
150
+ const rows = pairEntries(parsed.entries);
151
+ const latest = rows.at(-1);
152
+ if (!latest) {
153
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
154
+ }
155
+ const inProgress = latest.started !== '' && latest.done === '';
156
+ const state = inProgress ? 'in-progress' : latest.done ? 'idle' : 'unknown';
157
+ let stale = DASH;
158
+ if (inProgress) {
159
+ const started = parseActivityTime(latest.started);
160
+ stale = Number.isFinite(started) ? (now.getTime() - started > STALE_MS ? 'yes' : 'no') : 'unknown';
161
+ }
162
+ return {
163
+ state,
164
+ step: latest.step || DASH,
165
+ agent: latest.agent || DASH,
166
+ startedAt: latest.started || DASH,
167
+ doneAt: latest.done || DASH,
168
+ stale
169
+ };
170
+ }
171
+ function latestRunRecord(taskDir) {
172
+ const runsDir = path.join(taskDir, 'runs');
173
+ if (!fs.existsSync(runsDir))
174
+ return null;
175
+ const candidates = [];
176
+ for (const entry of fs.readdirSync(runsDir)) {
177
+ if (!entry.endsWith('.json'))
178
+ continue;
179
+ const filePath = path.join(runsDir, entry);
180
+ const stat = fs.statSync(filePath);
181
+ if (stat.isFile())
182
+ candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
183
+ }
184
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
185
+ for (const candidate of candidates) {
186
+ try {
187
+ const data = JSON.parse(fs.readFileSync(candidate.path, 'utf8'));
188
+ if (typeof data?.run_id === 'string' &&
189
+ typeof data?.engine === 'string' &&
190
+ typeof data?.container === 'string' &&
191
+ typeof data?.run_dir === 'string') {
192
+ return {
193
+ run_id: data.run_id,
194
+ engine: data.engine,
195
+ container: data.container,
196
+ run_dir: data.run_dir,
197
+ status_file: typeof data.status_file === 'string' ? data.status_file : `${data.run_dir}/status`,
198
+ log_file: typeof data.log_file === 'string' ? data.log_file : `${data.run_dir}/output.log`
199
+ };
153
200
  }
154
201
  }
202
+ catch {
203
+ // Ignore malformed local records and try the next newest file.
204
+ }
155
205
  }
156
- let pr = DASH;
157
- if (fm.pr_status === 'created' && fm.pr_number && /^\d+$/.test(fm.pr_number)) {
158
- const out = tryRun(run, 'gh', ['pr', 'view', fm.pr_number, '--json', 'state,statusCheckRollup']);
159
- if (out !== null) {
160
- try {
161
- const data = JSON.parse(out);
162
- const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
163
- const passed = rollup.filter((check) => check.conclusion === 'SUCCESS' || check.state === 'SUCCESS').length;
164
- pr = rollup.length > 0 ? `${data.state}, checks: ${passed}/${rollup.length}` : `${data.state}`;
165
- }
166
- catch {
167
- pr = DASH;
206
+ return null;
207
+ }
208
+ function readRuntimeFile(record, filePath, run) {
209
+ const command = commandForEngine(record.engine, 'docker', ['exec', record.container, 'cat', filePath]);
210
+ const output = tryRun(run, command.cmd, command.args);
211
+ return output === null ? null : output.trim();
212
+ }
213
+ function runtimeValue(record, name, run) {
214
+ const output = readRuntimeFile(record, `${record.run_dir}/${name}`, run);
215
+ return output ? output : DASH;
216
+ }
217
+ function collectRuntime(taskDir, workflow, run) {
218
+ const record = latestRunRecord(taskDir);
219
+ if (!record) {
220
+ return workflow.state === 'in-progress'
221
+ ? {
222
+ mode: 'unmanaged',
223
+ status: 'inferred-from-workflow',
224
+ run: DASH,
225
+ tmux: DASH,
226
+ startedAt: DASH,
227
+ finishedAt: DASH,
228
+ exitCode: DASH,
229
+ log: DASH
168
230
  }
169
- }
231
+ : {
232
+ mode: 'none',
233
+ status: DASH,
234
+ run: DASH,
235
+ tmux: DASH,
236
+ startedAt: DASH,
237
+ finishedAt: DASH,
238
+ exitCode: DASH,
239
+ log: DASH
240
+ };
170
241
  }
171
- return { issue, pr };
242
+ const status = readRuntimeFile(record, record.status_file, run)?.trim() || 'unknown';
243
+ const session = runtimeValue(record, 'session', run);
244
+ const window = runtimeValue(record, 'window', run);
245
+ const pane = runtimeValue(record, 'pane', run);
246
+ const tmux = session !== DASH && window !== DASH && pane !== DASH ? `${session}:${window}:${pane}` : DASH;
247
+ return {
248
+ mode: 'managed-tmux',
249
+ status,
250
+ run: record.run_id,
251
+ tmux,
252
+ startedAt: runtimeValue(record, 'started_at', run),
253
+ finishedAt: runtimeValue(record, 'finished_at', run),
254
+ exitCode: runtimeValue(record, 'exit_code', run),
255
+ log: record.log_file
256
+ };
172
257
  }
173
258
  // Indent each label/value pair by two spaces and pad labels to a common width so
174
259
  // every section reads as an aligned "key value" block.
@@ -189,6 +274,24 @@ function renderStatus(model) {
189
274
  else {
190
275
  lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
191
276
  }
277
+ lines.push('', 'Workflow', ...renderPairs([
278
+ ['state', model.workflow.state],
279
+ ['step', model.workflow.step],
280
+ ['agent', model.workflow.agent],
281
+ ['started_at', model.workflow.startedAt],
282
+ ['done_at', model.workflow.doneAt],
283
+ ['stale', model.workflow.stale]
284
+ ]));
285
+ lines.push('', 'Runtime', ...renderPairs([
286
+ ['mode', model.runtime.mode],
287
+ ['status', model.runtime.status],
288
+ ['run', model.runtime.run],
289
+ ['tmux', model.runtime.tmux],
290
+ ['started_at', model.runtime.startedAt],
291
+ ['finished_at', model.runtime.finishedAt],
292
+ ['exit_code', model.runtime.exitCode],
293
+ ['log', model.runtime.log]
294
+ ]));
192
295
  lines.push('', 'Git', ...renderPairs([
193
296
  ['current', model.git.current],
194
297
  ['frontmatter', model.git.frontmatter],
@@ -197,11 +300,6 @@ function renderStatus(model) {
197
300
  ['uncommitted', model.git.uncommitted],
198
301
  ['ahead/behind', model.git.aheadBehind]
199
302
  ]));
200
- const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
201
- lines.push('', 'Platform', ...renderPairs([
202
- [issueLabel, model.platform.issue],
203
- ['pr', model.platform.pr]
204
- ]));
205
303
  return lines;
206
304
  }
207
305
  function status(args = []) {
@@ -221,19 +319,20 @@ function status(args = []) {
221
319
  const fm = parseTaskFrontmatter(content);
222
320
  const run = makeRunner(resolved.repoRoot);
223
321
  const artifacts = enumerateArtifacts(resolved.taskDir);
322
+ const workflow = collectWorkflow(content);
224
323
  const model = {
225
324
  taskId: resolved.taskId,
226
325
  shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
227
326
  title: extractTitle(content),
228
- issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
229
327
  metadata: collectMetadata(fm),
230
328
  artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
231
- git: collectGit(fm.branch ?? '', run),
232
- platform: collectPlatform(fm, run)
329
+ workflow,
330
+ runtime: collectRuntime(resolved.taskDir, workflow, run),
331
+ git: collectGit(fm.branch ?? '', run)
233
332
  };
234
333
  for (const line of renderStatus(model)) {
235
334
  process.stdout.write(`${line}\n`);
236
335
  }
237
336
  }
238
- export { status, makeRunner, collectMetadata, groupArtifacts, collectGit, collectPlatform, renderStatus, METADATA_KEYS };
337
+ export { status, makeRunner, collectMetadata, groupArtifacts, collectGit, collectWorkflow, collectRuntime, renderStatus, METADATA_KEYS };
239
338
  //# sourceMappingURL=status.js.map
@@ -2,6 +2,7 @@ const USAGE = `Usage: ai task <command> [options]
2
2
 
3
3
  Commands:
4
4
  cat <ref> <artifact | N> Print a task artifact (by name or number)
5
+ decisions, d <ref> [selector] List human-decision (HD-) items, or show one's detail
5
6
  files <ref> List artifacts in a task dir (numbered)
6
7
  grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
7
8
  issue-body <ref> [--template <path>] Print a deterministic Issue body from task.md (描述 + 需求, or an Issue Form)
@@ -13,6 +14,8 @@ Commands:
13
14
  Examples:
14
15
  ai task cat 11 analysis
15
16
  ai task cat 11 3
17
+ ai task decisions 11
18
+ ai task d 11 HD-3 --format markdown
16
19
  ai task files 11
17
20
  ai task grep resolveArtifact
18
21
  ai task grep resolveArtifact 11
@@ -42,6 +45,12 @@ export async function runTask(args) {
42
45
  cat(rest);
43
46
  break;
44
47
  }
48
+ case 'decisions':
49
+ case 'd': {
50
+ const { decisions } = await import("./commands/decisions.js");
51
+ decisions(rest);
52
+ break;
53
+ }
45
54
  case 'files': {
46
55
  const { files } = await import("./commands/files.js");
47
56
  files(rest);
@@ -0,0 +1,66 @@
1
+ // Shared parser for the task.md `## 审查分歧账本` (Review Disagreement Ledger).
2
+ // Single source of truth for ledger row parsing across `ai task` commands
3
+ // (log, decisions) — see the plan's D1 constraint "no third parser". The gate
4
+ // parser in `.agents/scripts/validate-artifact.js` is a separate concern and
5
+ // must be kept semantically in sync by hand (noted in review-handshake.md).
6
+ // The ledger H2 heading is language-dependent (zh template / en template).
7
+ const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
8
+ const NEXT_H2_RE = /^##\s/;
9
+ // Terminal statuses the completion gates treat as resolved.
10
+ const LEDGER_TERMINAL = new Set(['confirmed', 'closed', 'human-decided']);
11
+ // Statuses that represent an executor-raised human-decision row (pending or done).
12
+ const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
13
+ // Parse all rows of the disagreement ledger table. Skips the heading, the
14
+ // header row (`| id | ... |`) and the `|---|` separator; ignores non-`|` lines.
15
+ // Rows with fewer than 6 columns are skipped (mirrors the JS gate parser).
16
+ function parseLedger(content) {
17
+ const lines = content.split('\n');
18
+ let i = 0;
19
+ while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]))
20
+ i += 1;
21
+ if (i >= lines.length)
22
+ return [];
23
+ const rows = [];
24
+ for (let j = i + 1; j < lines.length; j += 1) {
25
+ if (NEXT_H2_RE.test(lines[j]))
26
+ break;
27
+ const line = lines[j].trim();
28
+ if (!line.startsWith('|'))
29
+ continue;
30
+ const cells = line
31
+ .split('|')
32
+ .slice(1, -1)
33
+ .map((cell) => cell.trim());
34
+ if (cells.length < 6)
35
+ continue;
36
+ if (cells[0] === 'id' || /^-+$/.test(cells[0] ?? ''))
37
+ continue;
38
+ rows.push({
39
+ id: cells[0],
40
+ stage: cells[1],
41
+ round: cells[2],
42
+ severity: cells[3],
43
+ status: cells[4],
44
+ evidence: cells[5]
45
+ });
46
+ }
47
+ return rows;
48
+ }
49
+ // Allocate the next globally-unique human-decision id. Scans every `HD-<n>`
50
+ // already present in the ledger (across all stages) and returns `HD-{max+1}`,
51
+ // or `HD-1` when none exist. Global monotonic allocation prevents id collisions
52
+ // across the analysis / plan / code stages (plan PL-2).
53
+ function nextHdId(rows) {
54
+ let max = 0;
55
+ for (const row of rows) {
56
+ const m = /^HD-(\d+)$/.exec(row.id);
57
+ if (!m)
58
+ continue;
59
+ const n = Number.parseInt(m[1], 10);
60
+ if (n > max)
61
+ max = n;
62
+ }
63
+ return `HD-${max + 1}`;
64
+ }
65
+ export { parseLedger, nextHdId, LEDGER_TERMINAL, HUMAN_DECISION_STATUSES };
66
+ //# sourceMappingURL=ledger.js.map
@@ -40,5 +40,34 @@ function findSectionHeading(content, aliases) {
40
40
  }
41
41
  return aliases[0];
42
42
  }
43
- export { extractSection, findSectionHeading };
43
+ /**
44
+ * Return the body of the first `### {headingPrefix}` sub-section, from the
45
+ * heading line (inclusive) to the next `### ` / `## ` heading or EOF. Used to
46
+ * pull a single `### HD-N` human-decision detail block out of an artifact. The
47
+ * prefix must be followed by a word boundary so `HD-1` does not match `HD-10`
48
+ * (e.g. `### HD-1`, `### HD-1:标题`, `### HD-1 [needs-human-decision]`). Leading
49
+ * and trailing blank lines are trimmed. Returns '' when no match is present.
50
+ */
51
+ function extractSubSection(content, headingPrefix) {
52
+ const lines = content.split('\n');
53
+ const headRe = new RegExp(`^###\\s+${escapeRegExp(headingPrefix)}(?![\\w-])`);
54
+ let start = -1;
55
+ for (let i = 0; i < lines.length; i += 1) {
56
+ if (headRe.test(lines[i].trim())) {
57
+ start = i;
58
+ break;
59
+ }
60
+ }
61
+ if (start === -1)
62
+ return '';
63
+ let end = lines.length;
64
+ for (let i = start + 1; i < lines.length; i += 1) {
65
+ if (/^###?\s+/.test(lines[i])) {
66
+ end = i;
67
+ break;
68
+ }
69
+ }
70
+ return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
71
+ }
72
+ export { extractSection, findSectionHeading, extractSubSection };
44
73
  //# sourceMappingURL=sections.js.map
package/lib/decide.ts ADDED
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { VERSION } from './version.ts';
5
+ import { resolveTaskRef } from './task/resolve-ref.ts';
6
+
7
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
8
+
9
+ type DecideOptions = {
10
+ repoRoot?: string;
11
+ now?: () => string;
12
+ version?: string;
13
+ };
14
+
15
+ function detectRepoRoot(): string {
16
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
17
+ encoding: 'utf8',
18
+ stdio: ['pipe', 'pipe', 'pipe']
19
+ }).trim();
20
+ }
21
+
22
+ function defaultNow(): string {
23
+ return new Intl.DateTimeFormat('sv-SE', {
24
+ timeZoneName: 'longOffset',
25
+ year: 'numeric',
26
+ month: '2-digit',
27
+ day: '2-digit',
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ second: '2-digit',
31
+ hour12: false
32
+ })
33
+ .format(new Date())
34
+ .replace(' GMT', '');
35
+ }
36
+
37
+ function taskPath(repoRoot: string, ref: string): string {
38
+ if (!TASK_ID_RE.test(ref)) {
39
+ const resolved = resolveTaskRef(ref);
40
+ if (!resolved.ok) throw new Error(resolved.message);
41
+ if (!resolved.taskDir.includes(`${path.join('.agents', 'workspace', 'active')}${path.sep}`)) {
42
+ throw new Error(`task ${resolved.taskId} is not active`);
43
+ }
44
+ return resolved.taskMdPath;
45
+ }
46
+ const candidate = path.join(repoRoot, '.agents', 'workspace', 'active', ref, 'task.md');
47
+ if (!fs.existsSync(candidate)) throw new Error(`active task not found: ${ref}`);
48
+ return candidate;
49
+ }
50
+
51
+ function replaceFrontmatterField(content: string, field: string, value: string): string {
52
+ const re = new RegExp(`^${field}:.*$`, 'm');
53
+ if (re.test(content)) return content.replace(re, `${field}: ${value}`);
54
+ return content.replace(/^---\n/, `---\n${field}: ${value}\n`);
55
+ }
56
+
57
+ function replaceLedgerRow(content: string, hdId: string): { content: string; found: boolean; pending: boolean } {
58
+ const lines = content.split('\n');
59
+ let found = false;
60
+ let pending = false;
61
+ for (let i = 0; i < lines.length; i += 1) {
62
+ const line = lines[i] as string;
63
+ if (!line.trim().startsWith(`| ${hdId} |`)) continue;
64
+ found = true;
65
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
66
+ if (cells[4] !== 'needs-human-decision') break;
67
+ pending = true;
68
+ cells[4] = 'human-decided';
69
+ cells[5] = `task.md#${hdId}`;
70
+ lines[i] = `| ${cells.join(' | ')} |`;
71
+ break;
72
+ }
73
+ return { content: lines.join('\n'), found, pending };
74
+ }
75
+
76
+ function appendUnderHeading(content: string, heading: string, block: string): string {
77
+ if (!content.includes(`${heading}\n`)) {
78
+ return `${content.trimEnd()}\n\n${heading}\n\n${block}\n`;
79
+ }
80
+ const idx = content.indexOf(`${heading}\n`) + heading.length + 1;
81
+ const before = content.slice(0, idx);
82
+ const after = content.slice(idx);
83
+ return `${before}\n${block}\n${after.replace(/^\n/, '')}`;
84
+ }
85
+
86
+ export async function decide(args: string[], options: DecideOptions = {}): Promise<number> {
87
+ const [taskRef, hdId, ...decisionParts] = args;
88
+ if (!taskRef || !hdId || decisionParts.length === 0) {
89
+ process.stderr.write('Usage: ai decide <task-ref> <HD-id> <decision>\n');
90
+ return 1;
91
+ }
92
+ try {
93
+ const repoRoot = options.repoRoot ?? detectRepoRoot();
94
+ const file = taskPath(repoRoot, taskRef);
95
+ let content = fs.readFileSync(file, 'utf8');
96
+ const replaced = replaceLedgerRow(content, hdId);
97
+ if (!replaced.found) throw new Error(`${hdId} not found in review ledger`);
98
+ if (!replaced.pending) throw new Error(`${hdId} is not needs-human-decision`);
99
+ content = replaced.content;
100
+ const now = (options.now ?? defaultNow)();
101
+ content = replaceFrontmatterField(content, 'updated_at', now);
102
+ content = replaceFrontmatterField(content, 'agent_infra_version', options.version ?? VERSION);
103
+ const decision = decisionParts.join(' ');
104
+ content = appendUnderHeading(
105
+ content,
106
+ '## 人工裁决',
107
+ `### ${hdId}\n\n- **裁决时间**:${now}\n- **裁决结果**:${decision}`
108
+ );
109
+ content = appendUnderHeading(
110
+ content,
111
+ '## 活动日志',
112
+ `- ${now} — **Human Decision** by human — ${hdId} decided`
113
+ );
114
+ fs.writeFileSync(file, content);
115
+ return 0;
116
+ } catch (error) {
117
+ process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
118
+ return 1;
119
+ }
120
+ }
121
+
122
+ export async function cmdDecide(args: string[]): Promise<void> {
123
+ process.exitCode = await decide(args);
124
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export type RunProcessResult = {
6
+ exitCode: number | null;
7
+ signal?: NodeJS.Signals | null;
8
+ stdout?: string;
9
+ stderr?: string;
10
+ };
11
+
12
+ function resolveCommand(file: string): string {
13
+ if (process.platform !== 'win32' || path.extname(file)) {
14
+ return file;
15
+ }
16
+
17
+ const pathValue = process.env.Path || process.env.PATH || '';
18
+ const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
19
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
20
+ for (const extension of extensions) {
21
+ const lowerCandidate = path.join(dir, `${file}${extension.toLowerCase()}`);
22
+ if (fs.existsSync(lowerCandidate)) return lowerCandidate;
23
+ const upperCandidate = path.join(dir, `${file}${extension.toUpperCase()}`);
24
+ if (fs.existsSync(upperCandidate)) return upperCandidate;
25
+ }
26
+ }
27
+
28
+ return file;
29
+ }
30
+
31
+ function needsShell(file: string): boolean {
32
+ return process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file);
33
+ }
34
+
35
+ export async function runHostCommand(command: string[]): Promise<RunProcessResult> {
36
+ const [file, ...args] = command;
37
+ if (!file) throw new Error('run: missing command');
38
+ return new Promise((resolve, reject) => {
39
+ const resolvedFile = resolveCommand(file);
40
+ const child = spawn(resolvedFile, args, {
41
+ stdio: ['ignore', 'inherit', 'inherit'],
42
+ shell: needsShell(resolvedFile)
43
+ });
44
+ child.on('error', reject);
45
+ child.on('close', (exitCode, signal) => resolve({ exitCode, signal }));
46
+ });
47
+ }