@fitlab-ai/agent-infra 0.8.0 → 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 (63) hide show
  1. package/bin/cli.ts +1 -1
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/lib/run/index.js +96 -5
  4. package/dist/lib/sandbox/capture.js +113 -4
  5. package/dist/lib/sandbox/commands/create.js +3 -3
  6. package/dist/lib/sandbox/commands/enter.js +7 -3
  7. package/dist/lib/sandbox/credentials.js +42 -3
  8. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  9. package/dist/lib/task/commands/log.js +36 -14
  10. package/dist/lib/task/commands/status.js +144 -45
  11. package/lib/run/index.ts +119 -6
  12. package/lib/sandbox/capture.ts +136 -5
  13. package/lib/sandbox/commands/create.ts +3 -3
  14. package/lib/sandbox/commands/enter.ts +8 -2
  15. package/lib/sandbox/credentials.ts +57 -3
  16. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  17. package/lib/task/commands/log.ts +36 -14
  18. package/lib/task/commands/status.ts +200 -58
  19. package/package.json +4 -3
  20. package/templates/.agents/rules/next-step-output.en.md +1 -1
  21. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  22. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  23. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  24. package/templates/.agents/rules/task-management.en.md +1 -1
  25. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  26. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  27. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  28. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  29. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  30. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  31. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  32. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  33. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  34. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  35. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  36. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  37. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  38. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  39. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  40. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  41. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  42. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  43. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  44. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  45. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  46. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  47. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  48. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  49. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  50. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  51. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  52. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  53. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  54. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  55. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  56. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  57. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  58. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  59. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  60. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  61. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  62. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  63. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
@@ -55,6 +55,11 @@ type InspectOptions = {
55
55
  envFn?: EnvFn;
56
56
  };
57
57
 
58
+ type ProviderAuthOptions = {
59
+ readFn?: ReadFn;
60
+ existsFn?: ExistsFn;
61
+ };
62
+
58
63
  type WriteHostOptions = {
59
64
  execFn?: ExecFn;
60
65
  mkdirFn?: typeof fs.mkdirSync;
@@ -114,6 +119,14 @@ const REDACTION_PATTERNS = [
114
119
  { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
115
120
  ];
116
121
 
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ function hasNonEmptyString(value: unknown): boolean {
127
+ return typeof value === 'string' && value.trim().length > 0;
128
+ }
129
+
117
130
  function errorMessage(error: unknown): string {
118
131
  return error instanceof Error ? error.message : 'unknown error';
119
132
  }
@@ -219,6 +232,35 @@ export function validateClaudeCredentialsEnvOverride(env: NodeJS.ProcessEnv = pr
219
232
  }
220
233
  }
221
234
 
235
+ export function hasClaudeProviderAuthInSettings(settings: unknown): boolean {
236
+ if (!isRecord(settings)) {
237
+ return false;
238
+ }
239
+
240
+ const env = isRecord(settings.env) ? settings.env : {};
241
+ return hasNonEmptyString(env.ANTHROPIC_AUTH_TOKEN)
242
+ || hasNonEmptyString(env.ANTHROPIC_API_KEY)
243
+ || hasNonEmptyString(settings.apiKeyHelper);
244
+ }
245
+
246
+ export function hasClaudeProviderAuth(home: string, options: ProviderAuthOptions = {}): boolean {
247
+ const {
248
+ readFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'),
249
+ existsFn = fs.existsSync
250
+ } = options;
251
+ const settingsPath = path.join(home, '.claude', 'settings.json');
252
+
253
+ if (!existsFn(settingsPath)) {
254
+ return false;
255
+ }
256
+
257
+ try {
258
+ return hasClaudeProviderAuthInSettings(JSON.parse(readFn(settingsPath)));
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
222
264
  // Reconcile treats the freshest valid endpoint as authoritative so sandbox
223
265
  // token rotations can flow back to the host credential store.
224
266
  function validateClaudeCredentialsBlob(raw: unknown, blob: string | null = null): CredentialInspection {
@@ -595,13 +637,22 @@ export function prepareClaudeCredentials(
595
637
  resolvedTools: ResolvedTool[],
596
638
  extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
597
639
  writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
598
- inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
640
+ inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus,
641
+ providerAuthFn: (home: string) => boolean = hasClaudeProviderAuth
599
642
  ): ClaudeCredentialOutcome {
600
643
  const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
601
644
  if (!claudeCodeEntry) {
602
645
  return { status: 'NOT_APPLICABLE' };
603
646
  }
604
647
 
648
+ const providerAuthAvailable = () => {
649
+ try {
650
+ return providerAuthFn(home);
651
+ } catch {
652
+ return false;
653
+ }
654
+ };
655
+
605
656
  let blob: string | null = null;
606
657
  const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
607
658
  const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
@@ -612,7 +663,7 @@ export function prepareClaudeCredentials(
612
663
  blob = inspection.blob;
613
664
  break;
614
665
  case 'MISSING':
615
- return { status: 'SKIPPED' };
666
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
616
667
  case 'KEYCHAIN_LOCKED':
617
668
  throw new Error([
618
669
  'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
@@ -620,6 +671,9 @@ export function prepareClaudeCredentials(
620
671
  buildLockedGuidance()
621
672
  ].join('\n'));
622
673
  case 'STALE_ACCESS':
674
+ if (providerAuthAvailable()) {
675
+ return { status: 'OK' };
676
+ }
623
677
  throw new Error([
624
678
  'Claude Code credentials on host are invalid or expired.',
625
679
  '',
@@ -648,7 +702,7 @@ export function prepareClaudeCredentials(
648
702
  } else {
649
703
  blob = extractFn(home);
650
704
  if (!blob) {
651
- return { status: 'SKIPPED' };
705
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
652
706
  }
653
707
  }
654
708
 
@@ -61,6 +61,19 @@ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/c
61
61
  #!/bin/sh
62
62
  set -eu
63
63
 
64
+ SETTINGS_FILE="/home/devuser/.claude/settings.json"
65
+ if [ -r "$SETTINGS_FILE" ] && jq -e '
66
+ def has_nonempty($v):
67
+ if ($v | type) == "string" then ($v | gsub("^\\s+|\\s+$"; "") | length) > 0 else false end;
68
+ (if type == "object" then . else {} end) as $settings
69
+ | (if ($settings.env | type) == "object" then $settings.env else {} end) as $env
70
+ | has_nonempty($env.ANTHROPIC_AUTH_TOKEN)
71
+ or has_nonempty($env.ANTHROPIC_API_KEY)
72
+ or has_nonempty($settings.apiKeyHelper)
73
+ ' "$SETTINGS_FILE" >/dev/null 2>&1; then
74
+ exit 0
75
+ fi
76
+
64
77
  CRED_FILE="/home/devuser/.claude/.credentials.json"
65
78
  [ -r "$CRED_FILE" ] || exit 0
66
79
 
@@ -13,7 +13,7 @@ completion time (or '(in progress)' while still running).
13
13
  Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
14
14
  A human-executed step shows AGENT as 'human' and, when it has no start marker,
15
15
  a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
16
- the verdict list, right after blockers/major/minor: manual-verify (env-blocked)
16
+ the verdict list, right after blockers/major/minor: manual-validation
17
17
  and human-decision (current ledger stage total).
18
18
  `;
19
19
 
@@ -46,10 +46,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
46
46
  // note these differ from the `.airc.json` long names claude-code/gemini-cli).
47
47
  // Any other executor token (a human name, possibly CJK) is treated as human.
48
48
  const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
49
- const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
50
- // Same match plus any leading whitespace, so folding the count into the verdict
51
- // text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
52
- const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
53
49
  const REVIEW_STAGE_PREFIXES: { prefix: string; stage: ReviewStage }[] = [
54
50
  { prefix: 'Review Analysis', stage: 'analysis' },
55
51
  { prefix: 'Review Plan', stage: 'plan' },
@@ -126,9 +122,31 @@ function reviewStageForStep(step: string): ReviewStage | undefined {
126
122
  return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
127
123
  }
128
124
 
125
+ function splitArtifactSuffix(note: string): { verdict: string; suffix: string } {
126
+ const arrow = note.indexOf(' → ');
127
+ return arrow === -1 ? { verdict: note, suffix: '' } : { verdict: note.slice(0, arrow), suffix: note.slice(arrow) };
128
+ }
129
+
130
+ function fieldNumber(field: string, label: string): number | undefined {
131
+ const trimmed = field.trim();
132
+ const colon = trimmed.indexOf(':');
133
+ if (colon === -1) return undefined;
134
+ if (trimmed.slice(0, colon).trim().toLowerCase() !== label) return undefined;
135
+ const value = Number(trimmed.slice(colon + 1).trim());
136
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
137
+ }
138
+
129
139
  function humanValidationCount(note: string): number {
130
- const match = ENV_BLOCKED_RE.exec(note);
131
- return match ? Number(match[1]) : 0;
140
+ const { verdict } = splitArtifactSuffix(note);
141
+ for (const field of verdict.split(',')) {
142
+ const value = fieldNumber(field, 'manual-validation');
143
+ if (value !== undefined) return value;
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ function isHumanCountField(field: string): boolean {
149
+ return fieldNumber(field, 'manual-validation') !== undefined || fieldNumber(field, 'human-decision') !== undefined;
132
150
  }
133
151
 
134
152
  // A step is human-executed when its agent token is not a known AI token. Take
@@ -141,13 +159,17 @@ function isHumanAgent(agent: string): boolean {
141
159
 
142
160
  // Fold the two human counts into a review row's verdict NOTE: comma-joined, right
143
161
  // after the blockers/major/minor list and before the ` → artifact` link, mirroring
144
- // the review count line. The raw `(+ n env-blocked)` fragment is dropped so the
145
- // env-blocked number is not shown twice (it becomes the manual-verify count).
146
- function foldHumanCounts(note: string, decisions: number, envBlocked: number): string {
147
- const base = note.replace(ENV_BLOCKED_STRIP_RE, '');
148
- const group = `Manual-verify: ${envBlocked}, Human-decision: ${decisions}`;
149
- const arrow = base.indexOf(' → ');
150
- return arrow === -1 ? `${base}, ${group}` : `${base.slice(0, arrow)}, ${group}${base.slice(arrow)}`;
162
+ // the review count line. Review done notes already carry `Manual-validation` as a
163
+ // source field, so build the final verdict field list once instead of cleaning a
164
+ // previously rendered string.
165
+ function foldHumanCounts(note: string, decisions: number, manualValidation: number): string {
166
+ const { verdict, suffix } = splitArtifactSuffix(note);
167
+ const fields = verdict
168
+ .split(',')
169
+ .map((field) => field.trim())
170
+ .filter((field) => field !== '' && !isHumanCountField(field));
171
+ const group = `Manual-validation: ${manualValidation}, Human-decision: ${decisions}`;
172
+ return `${[...fields, group].join(', ')}${suffix}`;
151
173
  }
152
174
 
153
175
  function log(args: string[] = []): void {
@@ -1,24 +1,27 @@
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.ts';
3
5
  import { resolveTaskRef } from '../resolve-ref.ts';
4
6
  import { enumerateArtifacts, type Artifact } from '../artifacts.ts';
5
7
  import { parseTaskFrontmatter, extractTitle, type Frontmatter } from '../frontmatter.ts';
6
8
  import { loadShortIdByTaskId } from '../short-id.ts';
9
+ import { parseActivityLog, pairEntries } from './log.ts';
7
10
 
8
11
  const USAGE = `Usage: ai task status <N | #N | TASK-id>
9
12
 
10
- Prints an aggregated "health check" view for a task: header, metadata, an
11
- artifacts summary, git branch state, and best-effort GitHub issue/PR status.
13
+ Prints an aggregated "health check" view for a task: header, metadata,
14
+ artifacts, workflow/runtime execution state, and git branch state.
12
15
  <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
13
16
 
14
- Git and Platform rows are best-effort: a failed git/gh call degrades that row to
15
- '-' without failing the command.
17
+ Git rows are best-effort: a failed git call degrades that row to '-' without
18
+ failing the command.
16
19
  `;
17
20
 
18
21
  const DASH = '-';
19
22
 
20
23
  // Subprocess boundary: the single place this command shells out. Injectable so
21
- // the collectors below can be unit-tested without spawning git/gh. Returns the
24
+ // the collectors below can be unit-tested without spawning git. Returns the
22
25
  // command's stdout; throws (like execFileSync) on a non-zero exit or spawn error.
23
26
  type Runner = (file: string, args: string[]) => string;
24
27
 
@@ -27,7 +30,7 @@ function makeRunner(cwd: string): Runner {
27
30
  execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
28
31
  }
29
32
 
30
- // Run `run` and swallow any failure into null, so a single failing git/gh call
33
+ // Run `run` and swallow any failure into null, so a single failing git call
31
34
  // degrades only its own field instead of aborting the whole view.
32
35
  function tryRun(run: Runner, file: string, args: string[]): string | null {
33
36
  try {
@@ -47,9 +50,7 @@ const METADATA_KEYS = [
47
50
  'branch',
48
51
  'assigned_to',
49
52
  'created_at',
50
- 'updated_at',
51
- 'issue_number',
52
- 'pr_status'
53
+ 'updated_at'
53
54
  ] as const;
54
55
 
55
56
  function collectMetadata(fm: Frontmatter): [string, string][] {
@@ -155,55 +156,176 @@ function collectGit(frontmatterBranch: string, run: Runner): GitInfo {
155
156
  return { current, frontmatter, match, exists, uncommitted, aheadBehind };
156
157
  }
157
158
 
158
- type PlatformInfo = { issue: string; pr: string };
159
-
160
- function collectPlatform(fm: Frontmatter, run: Runner): PlatformInfo {
161
- let issue = DASH;
162
- if (fm.issue_number && /^\d+$/.test(fm.issue_number)) {
163
- const out = tryRun(run, 'gh', ['issue', 'view', fm.issue_number, '--json', 'state,labels']);
164
- if (out !== null) {
165
- try {
166
- const data = JSON.parse(out);
167
- const labels = Array.isArray(data.labels)
168
- ? data.labels.map((label: { name: string }) => label.name).join(', ')
169
- : '';
170
- issue = labels ? `${data.state} [${labels}]` : `${data.state}`;
171
- } catch {
172
- issue = DASH;
173
- }
174
- }
159
+ type WorkflowInfo = {
160
+ state: string;
161
+ step: string;
162
+ agent: string;
163
+ startedAt: string;
164
+ doneAt: string;
165
+ stale: string;
166
+ };
167
+
168
+ const STALE_MS = 60 * 60 * 1000;
169
+
170
+ function parseActivityTime(value: string): number {
171
+ const epoch = Date.parse(value.replace(' ', 'T'));
172
+ return Number.isFinite(epoch) ? epoch : Number.NaN;
173
+ }
174
+
175
+ function collectWorkflow(content: string, now: Date = new Date()): WorkflowInfo {
176
+ const parsed = parseActivityLog(content);
177
+ if (!parsed.sectionFound || parsed.entries.length === 0) {
178
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
179
+ }
180
+
181
+ const rows = pairEntries(parsed.entries);
182
+ const latest = rows.at(-1);
183
+ if (!latest) {
184
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
175
185
  }
176
186
 
177
- let pr = DASH;
178
- if (fm.pr_status === 'created' && fm.pr_number && /^\d+$/.test(fm.pr_number)) {
179
- const out = tryRun(run, 'gh', ['pr', 'view', fm.pr_number, '--json', 'state,statusCheckRollup']);
180
- if (out !== null) {
181
- try {
182
- const data = JSON.parse(out);
183
- const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
184
- const passed = rollup.filter(
185
- (check: { conclusion?: string; state?: string }) =>
186
- check.conclusion === 'SUCCESS' || check.state === 'SUCCESS'
187
- ).length;
188
- pr = rollup.length > 0 ? `${data.state}, checks: ${passed}/${rollup.length}` : `${data.state}`;
189
- } catch {
190
- pr = DASH;
187
+ const inProgress = latest.started !== '' && latest.done === '';
188
+ const state = inProgress ? 'in-progress' : latest.done ? 'idle' : 'unknown';
189
+ let stale = DASH;
190
+ if (inProgress) {
191
+ const started = parseActivityTime(latest.started);
192
+ stale = Number.isFinite(started) ? (now.getTime() - started > STALE_MS ? 'yes' : 'no') : 'unknown';
193
+ }
194
+
195
+ return {
196
+ state,
197
+ step: latest.step || DASH,
198
+ agent: latest.agent || DASH,
199
+ startedAt: latest.started || DASH,
200
+ doneAt: latest.done || DASH,
201
+ stale
202
+ };
203
+ }
204
+
205
+ type RuntimeInfo = {
206
+ mode: string;
207
+ status: string;
208
+ run: string;
209
+ tmux: string;
210
+ startedAt: string;
211
+ finishedAt: string;
212
+ exitCode: string;
213
+ log: string;
214
+ };
215
+
216
+ type ManagedRunRecord = {
217
+ run_id: string;
218
+ engine: string;
219
+ container: string;
220
+ run_dir: string;
221
+ status_file: string;
222
+ log_file: string;
223
+ };
224
+
225
+ function latestRunRecord(taskDir: string): ManagedRunRecord | null {
226
+ const runsDir = path.join(taskDir, 'runs');
227
+ if (!fs.existsSync(runsDir)) return null;
228
+ const candidates: { path: string; mtimeMs: number }[] = [];
229
+ for (const entry of fs.readdirSync(runsDir)) {
230
+ if (!entry.endsWith('.json')) continue;
231
+ const filePath = path.join(runsDir, entry);
232
+ const stat = fs.statSync(filePath);
233
+ if (stat.isFile()) candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
234
+ }
235
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
236
+
237
+ for (const candidate of candidates) {
238
+ try {
239
+ const data = JSON.parse(fs.readFileSync(candidate.path, 'utf8'));
240
+ if (
241
+ typeof data?.run_id === 'string' &&
242
+ typeof data?.engine === 'string' &&
243
+ typeof data?.container === 'string' &&
244
+ typeof data?.run_dir === 'string'
245
+ ) {
246
+ return {
247
+ run_id: data.run_id,
248
+ engine: data.engine,
249
+ container: data.container,
250
+ run_dir: data.run_dir,
251
+ status_file:
252
+ typeof data.status_file === 'string' ? data.status_file : `${data.run_dir}/status`,
253
+ log_file:
254
+ typeof data.log_file === 'string' ? data.log_file : `${data.run_dir}/output.log`
255
+ };
191
256
  }
257
+ } catch {
258
+ // Ignore malformed local records and try the next newest file.
192
259
  }
193
260
  }
194
261
 
195
- return { issue, pr };
262
+ return null;
263
+ }
264
+
265
+ function readRuntimeFile(record: ManagedRunRecord, filePath: string, run: Runner): string | null {
266
+ const command = commandForEngine(record.engine, 'docker', ['exec', record.container, 'cat', filePath]);
267
+ const output = tryRun(run, command.cmd, command.args);
268
+ return output === null ? null : output.trim();
269
+ }
270
+
271
+ function runtimeValue(record: ManagedRunRecord, name: string, run: Runner): string {
272
+ const output = readRuntimeFile(record, `${record.run_dir}/${name}`, run);
273
+ return output ? output : DASH;
274
+ }
275
+
276
+ function collectRuntime(taskDir: string, workflow: WorkflowInfo, run: Runner): RuntimeInfo {
277
+ const record = latestRunRecord(taskDir);
278
+ if (!record) {
279
+ return workflow.state === 'in-progress'
280
+ ? {
281
+ mode: 'unmanaged',
282
+ status: 'inferred-from-workflow',
283
+ run: DASH,
284
+ tmux: DASH,
285
+ startedAt: DASH,
286
+ finishedAt: DASH,
287
+ exitCode: DASH,
288
+ log: DASH
289
+ }
290
+ : {
291
+ mode: 'none',
292
+ status: DASH,
293
+ run: DASH,
294
+ tmux: DASH,
295
+ startedAt: DASH,
296
+ finishedAt: DASH,
297
+ exitCode: DASH,
298
+ log: DASH
299
+ };
300
+ }
301
+
302
+ const status = readRuntimeFile(record, record.status_file, run)?.trim() || 'unknown';
303
+ const session = runtimeValue(record, 'session', run);
304
+ const window = runtimeValue(record, 'window', run);
305
+ const pane = runtimeValue(record, 'pane', run);
306
+ const tmux = session !== DASH && window !== DASH && pane !== DASH ? `${session}:${window}:${pane}` : DASH;
307
+
308
+ return {
309
+ mode: 'managed-tmux',
310
+ status,
311
+ run: record.run_id,
312
+ tmux,
313
+ startedAt: runtimeValue(record, 'started_at', run),
314
+ finishedAt: runtimeValue(record, 'finished_at', run),
315
+ exitCode: runtimeValue(record, 'exit_code', run),
316
+ log: record.log_file
317
+ };
196
318
  }
197
319
 
198
320
  type StatusModel = {
199
321
  taskId: string;
200
322
  shortId: string;
201
323
  title: string;
202
- issueNumber: string;
203
324
  metadata: [string, string][];
204
325
  artifacts: { count: number; groups: { stage: string; files: string[] }[] };
326
+ workflow: WorkflowInfo;
327
+ runtime: RuntimeInfo;
205
328
  git: GitInfo;
206
- platform: PlatformInfo;
207
329
  };
208
330
 
209
331
  // Indent each label/value pair by two spaces and pad labels to a common width so
@@ -228,6 +350,34 @@ function renderStatus(model: StatusModel): string[] {
228
350
  lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
229
351
  }
230
352
 
353
+ lines.push(
354
+ '',
355
+ 'Workflow',
356
+ ...renderPairs([
357
+ ['state', model.workflow.state],
358
+ ['step', model.workflow.step],
359
+ ['agent', model.workflow.agent],
360
+ ['started_at', model.workflow.startedAt],
361
+ ['done_at', model.workflow.doneAt],
362
+ ['stale', model.workflow.stale]
363
+ ])
364
+ );
365
+
366
+ lines.push(
367
+ '',
368
+ 'Runtime',
369
+ ...renderPairs([
370
+ ['mode', model.runtime.mode],
371
+ ['status', model.runtime.status],
372
+ ['run', model.runtime.run],
373
+ ['tmux', model.runtime.tmux],
374
+ ['started_at', model.runtime.startedAt],
375
+ ['finished_at', model.runtime.finishedAt],
376
+ ['exit_code', model.runtime.exitCode],
377
+ ['log', model.runtime.log]
378
+ ])
379
+ );
380
+
231
381
  lines.push(
232
382
  '',
233
383
  'Git',
@@ -241,16 +391,6 @@ function renderStatus(model: StatusModel): string[] {
241
391
  ])
242
392
  );
243
393
 
244
- const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
245
- lines.push(
246
- '',
247
- 'Platform',
248
- ...renderPairs([
249
- [issueLabel, model.platform.issue],
250
- ['pr', model.platform.pr]
251
- ])
252
- );
253
-
254
394
  return lines;
255
395
  }
256
396
 
@@ -272,16 +412,17 @@ function status(args: string[] = []): void {
272
412
  const fm = parseTaskFrontmatter(content);
273
413
  const run = makeRunner(resolved.repoRoot);
274
414
  const artifacts = enumerateArtifacts(resolved.taskDir);
415
+ const workflow = collectWorkflow(content);
275
416
 
276
417
  const model: StatusModel = {
277
418
  taskId: resolved.taskId,
278
419
  shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
279
420
  title: extractTitle(content),
280
- issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
281
421
  metadata: collectMetadata(fm),
282
422
  artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
283
- git: collectGit(fm.branch ?? '', run),
284
- platform: collectPlatform(fm, run)
423
+ workflow,
424
+ runtime: collectRuntime(resolved.taskDir, workflow, run),
425
+ git: collectGit(fm.branch ?? '', run)
285
426
  };
286
427
 
287
428
  for (const line of renderStatus(model)) {
@@ -295,8 +436,9 @@ export {
295
436
  collectMetadata,
296
437
  groupArtifacts,
297
438
  collectGit,
298
- collectPlatform,
439
+ collectWorkflow,
440
+ collectRuntime,
299
441
  renderStatus,
300
442
  METADATA_KEYS
301
443
  };
302
- export type { Runner, GitInfo, PlatformInfo, StatusModel };
444
+ export type { Runner, GitInfo, WorkflowInfo, RuntimeInfo, StatusModel };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "installer"
44
44
  ],
45
45
  "dependencies": {
46
- "@clack/prompts": "1.6.0",
46
+ "@clack/prompts": "1.7.0",
47
47
  "@larksuiteoapi/node-sdk": "^1.68.0",
48
48
  "cross-spawn": "^7.0.6",
49
49
  "picocolors": "1.1.1",
@@ -58,13 +58,14 @@
58
58
  "typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.jschecks.json --noEmit",
59
59
  "test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\"",
60
60
  "test:core": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\"",
61
+ "check": "npm run typecheck && npm test",
61
62
  "test": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\"",
62
63
  "test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage \"--test-coverage-exclude=tests/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov \"tests/**/*.test.ts\"",
63
64
  "prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\""
64
65
  },
65
66
  "devDependencies": {
66
67
  "@types/cross-spawn": "^6.0.6",
67
- "@types/node": "^24.0.0",
68
+ "@types/node": "^22.20.0",
68
69
  "@types/semver": "^7.7.1",
69
70
  "typescript": "~6.0"
70
71
  },
@@ -59,7 +59,7 @@ Completed at: YYYY-MM-DD HH:mm:ss
59
59
  ```
60
60
 
61
61
  - Value command (local timezone, no offset): `date "+%Y-%m-%d %H:%M:%S"`
62
- - Position: it must be the last line of the entire user-facing output, after all "Next steps" commands. If a scenario has a conditional reminder line after the commands (e.g. the env-blocked reminder), the completion line goes after that reminder.
62
+ - Position: it must be the last line of the entire user-facing output, after all "Next steps" commands. If a scenario has a conditional reminder line after the commands (e.g. the manual-validation reminder), the completion line goes after that reminder.
63
63
  - This line is for terminal scanning only; it is never written to any artifact file or Issue/PR comment. The single source of truth for completion time remains the Activity Log in task.md.
64
64
 
65
65
  ## Pending human-decision pre-block (review-* only, when {h} > 0)
@@ -59,7 +59,7 @@ Completed at: YYYY-MM-DD HH:mm:ss
59
59
  ```
60
60
 
61
61
  - 取值命令(本地时区、不带偏移):`date "+%Y-%m-%d %H:%M:%S"`
62
- - 位置:必须是整段面向用户输出的最后一行,排在所有「下一步」命令之后。若某场景在命令之后还有条件性提醒行(如 env-blocked 提醒),收尾行排在该提醒行之后。
62
+ - 位置:必须是整段面向用户输出的最后一行,排在所有「下一步」命令之后。若某场景在命令之后还有条件性提醒行(如 manual-validation 提醒),收尾行排在该提醒行之后。
63
63
  - 该行只用于终端扫视,不写入任何产物文件或 Issue/PR 评论;完成时刻的单一事实源仍是 task.md 的 Activity Log。
64
64
 
65
65
  ## 人工裁决待办前置块(review-* 专用,{h} > 0 时)
@@ -34,7 +34,7 @@ Aggregation rules:
34
34
  - if one artifact class is missing, treat it as "no data for this stage" and continue
35
35
  - Manual verification section: include only post-code-stage checks that still require a human to execute or judge and that the AI cannot close on its own.
36
36
  - **Admission boundary**: the verification result depends on a real environment, permissions, account, external system, or human judgment, and cannot be closed by an agent rerunning tests, adding checks, or continuing the fix loop.
37
- - **Sources**: `review-code*` "Environment-Blocked Findings", plus `code*` items that satisfy the boundary above.
37
+ - **Sources**: `review-code*` "Manual Validation Items", plus `code*` items that satisfy the boundary above.
38
38
  - **Wording**: each retained item must state at least "what to verify + location (file/change/scope) + why only a human can verify it".
39
39
  - **Empty rendering**: when there are no retained items, do NOT use the ⚠️ alarm style (it falsely implies a problem). Render the whole block as: heading `### ✅ No Manual Verification Needed` and a single line `No items in this change require manual confirmation.`, with no item list. Only use the `### ⚠️ Manual Verification Required` heading + item list when retained items exist.
40
40
 
@@ -51,7 +51,7 @@ Use this canonical comment body template:
51
51
 
52
52
  **Updated At**: {current-time}
53
53
 
54
- {manual-verify-section}
54
+ {manual-validation-section}
55
55
 
56
56
  ### Key Technical Decisions
57
57
 
@@ -72,7 +72,7 @@ Use this canonical comment body template:
72
72
  *Generated by {agent} · Internal tracking: {task-id}*
73
73
  ```
74
74
 
75
- > Render `{manual-verify-section}` per the "manual verification section" aggregation rule above: with retained items → `### ⚠️ Manual Verification Required` heading + quote + item list; with none → `### ✅ No Manual Verification Needed` heading + a single line `No items in this change require manual confirmation.` (no ⚠️, no list).
75
+ > Render `{manual-validation-section}` per the "manual verification section" aggregation rule above: with retained items → `### ⚠️ Manual Verification Required` heading + quote + item list; with none → `### ✅ No Manual Verification Needed` heading + a single line `No items in this change require manual confirmation.` (no ⚠️, no list).
76
76
 
77
77
  ## Comment Lookup And Update
78
78
 
@@ -34,7 +34,7 @@
34
34
  - 某一类产物缺失时,按“无该阶段数据”处理并继续生成
35
35
  - 需人工校验段落:只收进入 code 阶段后仍需人实际执行或判断、AI 无法自行关闭的校验点。
36
36
  - **准入边界**:校验结论依赖真实环境、权限、账号、外部系统或人工判断,且无法通过 agent 重跑测试、补充检查或继续修复自行关闭。
37
- - **来源**:`review-code*` 的「环境性遗留」,以及 `code*` 中满足上述边界的校验点。
37
+ - **来源**:`review-code*` 的「人工校验项」,以及 `code*` 中满足上述边界的校验点。
38
38
  - **写法**:每条保留项至少写明「校验什么 + 定位(文件/改动/范围)+ 为什么只能由人校验」。
39
39
  - **空集渲染**:无保留项时,不要使用 ⚠️ 告警样式(会让人误以为有问题)。整段降级渲染为:标题 `### ✅ 无需人工校验`,正文一行 `本次改动无需人工确认事项。`,不带条目列表。有保留项时才用 `### ⚠️ 需人工校验` 标题 + 条目列表。
40
40
 
@@ -51,7 +51,7 @@
51
51
 
52
52
  **更新时间**:{当前时间}
53
53
 
54
- {manual-verify-section}
54
+ {manual-validation-section}
55
55
 
56
56
  ### 关键技术决策
57
57
 
@@ -72,7 +72,7 @@
72
72
  *由 {agent} 自动生成 · 内部追踪:{task-id}*
73
73
  ```
74
74
 
75
- > `{manual-verify-section}` 按上文「需人工校验段落」聚合规则渲染:有保留项 → `### ⚠️ 需人工校验` 标题 + 引用说明 + 条目列表;无保留项 → `### ✅ 无需人工校验` 标题 + 一行 `本次改动无需人工确认事项。`(不带 ⚠️、不带列表)。
75
+ > `{manual-validation-section}` 按上文「需人工校验段落」聚合规则渲染:有保留项 → `### ⚠️ 需人工校验` 标题 + 引用说明 + 条目列表;无保留项 → `### ✅ 无需人工校验` 标题 + 一行 `本次改动无需人工确认事项。`(不带 ⚠️、不带列表)。
76
76
 
77
77
  ## 评论查找与更新
78
78