@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.
- package/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/run/index.js +96 -5
- package/dist/lib/sandbox/capture.js +113 -4
- package/dist/lib/sandbox/commands/create.js +3 -3
- package/dist/lib/sandbox/commands/enter.js +7 -3
- package/dist/lib/sandbox/credentials.js +42 -3
- package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/dist/lib/task/commands/log.js +36 -14
- package/dist/lib/task/commands/status.js +144 -45
- package/lib/run/index.ts +119 -6
- package/lib/sandbox/capture.ts +136 -5
- package/lib/sandbox/commands/create.ts +3 -3
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/credentials.ts +57 -3
- package/lib/sandbox/runtimes/base.dockerfile +13 -0
- package/lib/task/commands/log.ts +36 -14
- package/lib/task/commands/status.ts +200 -58
- package/package.json +4 -3
- package/templates/.agents/rules/next-step-output.en.md +1 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
- package/templates/.agents/rules/pr-sync.github.en.md +3 -3
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
- package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
- package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
- package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
- package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
- package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
- package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
- package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
- 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
|
|
package/lib/task/commands/log.ts
CHANGED
|
@@ -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-
|
|
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
|
|
131
|
-
|
|
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.
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
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,
|
|
11
|
-
artifacts
|
|
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
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
439
|
+
collectWorkflow,
|
|
440
|
+
collectRuntime,
|
|
299
441
|
renderStatus,
|
|
300
442
|
METADATA_KEYS
|
|
301
443
|
};
|
|
302
|
-
export type { Runner, GitInfo,
|
|
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.
|
|
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.
|
|
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": "^
|
|
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
|
|
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
|
-
- 位置:必须是整段面向用户输出的最后一行,排在所有「下一步」命令之后。若某场景在命令之后还有条件性提醒行(如
|
|
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*` "
|
|
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-
|
|
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-
|
|
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*`
|
|
37
|
+
- **来源**:`review-code*` 的「人工校验项」,以及 `code*` 中满足上述边界的校验点。
|
|
38
38
|
- **写法**:每条保留项至少写明「校验什么 + 定位(文件/改动/范围)+ 为什么只能由人校验」。
|
|
39
39
|
- **空集渲染**:无保留项时,不要使用 ⚠️ 告警样式(会让人误以为有问题)。整段降级渲染为:标题 `### ✅ 无需人工校验`,正文一行 `本次改动无需人工确认事项。`,不带条目列表。有保留项时才用 `### ⚠️ 需人工校验` 标题 + 条目列表。
|
|
40
40
|
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
**更新时间**:{当前时间}
|
|
53
53
|
|
|
54
|
-
{manual-
|
|
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-
|
|
75
|
+
> `{manual-validation-section}` 按上文「需人工校验段落」聚合规则渲染:有保留项 → `### ⚠️ 需人工校验` 标题 + 引用说明 + 条目列表;无保留项 → `### ✅ 无需人工校验` 标题 + 一行 `本次改动无需人工确认事项。`(不带 ⚠️、不带列表)。
|
|
76
76
|
|
|
77
77
|
## 评论查找与更新
|
|
78
78
|
|