@fitlab-ai/agent-infra 0.6.1 → 0.6.2
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/lib/defaults.json +1 -1
- package/dist/lib/sandbox/commands/create.js +27 -19
- package/dist/lib/sandbox/config.js +24 -6
- package/dist/lib/sandbox/credentials.js +43 -24
- package/dist/lib/sandbox/runtime-engines.js +27 -0
- package/dist/package.json +1 -1
- package/lib/defaults.json +1 -1
- package/lib/sandbox/commands/create.ts +29 -19
- package/lib/sandbox/config.ts +34 -6
- package/lib/sandbox/credentials.ts +49 -26
- package/lib/sandbox/runtime-engines.ts +39 -0
- package/package.json +3 -1
- package/templates/.agents/rules/create-issue.github.en.md +3 -3
- package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
- package/templates/.agents/rules/release-commands.github.en.md +9 -3
- package/templates/.agents/rules/release-commands.github.zh-CN.md +9 -3
- package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/create-pr/config/verify.json +1 -0
- package/templates/.agents/skills/create-release-note/SKILL.en.md +8 -11
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +8 -11
- package/templates/.agents/skills/create-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/plan-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +1 -1
package/README.md
CHANGED
|
@@ -777,7 +777,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
|
|
|
777
777
|
"project": "my-project",
|
|
778
778
|
"org": "my-org",
|
|
779
779
|
"language": "en",
|
|
780
|
-
"templateVersion": "v0.6.
|
|
780
|
+
"templateVersion": "v0.6.2",
|
|
781
781
|
"templates": {
|
|
782
782
|
"sources": [
|
|
783
783
|
{ "type": "local", "path": "~/private-templates" }
|
package/README.zh-CN.md
CHANGED
|
@@ -753,7 +753,7 @@ import-issue #42 从 GitHub Issue 导入任务
|
|
|
753
753
|
"project": "my-project",
|
|
754
754
|
"org": "my-org",
|
|
755
755
|
"language": "en",
|
|
756
|
-
"templateVersion": "v0.6.
|
|
756
|
+
"templateVersion": "v0.6.2",
|
|
757
757
|
"templates": {
|
|
758
758
|
"sources": [
|
|
759
759
|
{ "type": "local", "path": "~/private-templates" }
|
package/dist/lib/defaults.json
CHANGED
|
@@ -18,7 +18,7 @@ import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
|
|
|
18
18
|
import { validateSelinuxDisableEnv } from "../engines/selinux.js";
|
|
19
19
|
import { resolveBuildUid } from "../engines/native.js";
|
|
20
20
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
21
|
-
import {
|
|
21
|
+
import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
22
22
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
23
23
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
24
24
|
const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
|
|
@@ -825,11 +825,13 @@ export async function create(args) {
|
|
|
825
825
|
assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
|
|
826
826
|
const tools = resolveTools(effectiveConfig);
|
|
827
827
|
const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
|
|
828
|
-
//
|
|
829
|
-
// Claude Code credential
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
828
|
+
// Fatal credential states still fail before filesystem/docker side effects.
|
|
829
|
+
// A genuinely missing Claude Code credential only removes Claude Code's
|
|
830
|
+
// sandbox config and credential mounts for this create run.
|
|
831
|
+
const credentialOutcome = prepareClaudeCredentials(effectiveConfig.home, effectiveConfig.project, resolvedTools);
|
|
832
|
+
const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
|
|
833
|
+
? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
|
|
834
|
+
: resolvedTools;
|
|
833
835
|
const container = containerName(effectiveConfig, branch);
|
|
834
836
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
835
837
|
const shareCommon = shareCommonDir(effectiveConfig);
|
|
@@ -840,6 +842,11 @@ export async function create(args) {
|
|
|
840
842
|
const engine = detectEngine(effectiveConfig);
|
|
841
843
|
p.intro(pc.cyan('AI Sandbox'));
|
|
842
844
|
p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
|
|
845
|
+
if (credentialOutcome.status === 'SKIPPED') {
|
|
846
|
+
p.log.warn('Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
|
|
847
|
+
+ ' Claude Code is still installed in the image but will not be authenticated.\n'
|
|
848
|
+
+ ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".');
|
|
849
|
+
}
|
|
843
850
|
try {
|
|
844
851
|
p.log.step('Checking container engine...');
|
|
845
852
|
await ensureDocker(effectiveConfig, (detail) => {
|
|
@@ -913,7 +920,7 @@ export async function create(args) {
|
|
|
913
920
|
{
|
|
914
921
|
title: 'Preparing tool state',
|
|
915
922
|
task: async () => {
|
|
916
|
-
for (const { tool, dir } of
|
|
923
|
+
for (const { tool, dir } of effectiveResolvedTools) {
|
|
917
924
|
fs.mkdirSync(dir, { recursive: true });
|
|
918
925
|
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
|
|
919
926
|
const destination = path.join(dir, sandboxName);
|
|
@@ -946,7 +953,7 @@ export async function create(args) {
|
|
|
946
953
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
947
954
|
}
|
|
948
955
|
}
|
|
949
|
-
return `${
|
|
956
|
+
return `${effectiveResolvedTools.length} tool config directories ready`;
|
|
950
957
|
}
|
|
951
958
|
},
|
|
952
959
|
{
|
|
@@ -978,31 +985,32 @@ export async function create(args) {
|
|
|
978
985
|
const cachedGpg = needsGpg
|
|
979
986
|
? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
|
|
980
987
|
: null;
|
|
981
|
-
const envFile = buildContainerEnvFile(
|
|
988
|
+
const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
|
|
982
989
|
let hostShellConfig;
|
|
983
990
|
try {
|
|
984
|
-
const claudeCodeEntry =
|
|
991
|
+
const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
985
992
|
if (claudeCodeEntry) {
|
|
986
993
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
987
994
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
988
|
-
//
|
|
989
|
-
//
|
|
995
|
+
// prepareClaudeCredentials wrote the shared credentials file
|
|
996
|
+
// before this point. If credentials were missing, the
|
|
997
|
+
// claude-code entry was removed from effectiveResolvedTools.
|
|
990
998
|
}
|
|
991
|
-
const codexEntry =
|
|
999
|
+
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
992
1000
|
if (codexEntry) {
|
|
993
1001
|
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
|
|
994
1002
|
ensureCodexWorkspaceTrust(codexEntry.dir);
|
|
995
1003
|
}
|
|
996
|
-
const geminiEntry =
|
|
1004
|
+
const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
997
1005
|
if (geminiEntry) {
|
|
998
1006
|
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
999
1007
|
}
|
|
1000
|
-
const opencodeEntry =
|
|
1008
|
+
const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
|
|
1001
1009
|
if (opencodeEntry) {
|
|
1002
1010
|
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
|
|
1003
1011
|
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
|
|
1004
1012
|
}
|
|
1005
|
-
const toolVolumes =
|
|
1013
|
+
const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
|
|
1006
1014
|
'-v',
|
|
1007
1015
|
volumeArg(engine, dir, tool.containerMount)
|
|
1008
1016
|
]);
|
|
@@ -1017,7 +1025,7 @@ export async function create(args) {
|
|
|
1017
1025
|
'-v',
|
|
1018
1026
|
volumeArg(engine, hostPath, containerPath, ':ro')
|
|
1019
1027
|
]);
|
|
1020
|
-
const liveMountVolumes =
|
|
1028
|
+
const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
|
|
1021
1029
|
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1022
1030
|
.flatMap(({ hostPath, containerSubpath }) => [
|
|
1023
1031
|
'-v',
|
|
@@ -1105,7 +1113,7 @@ export async function create(args) {
|
|
|
1105
1113
|
: 'Host GPG keys unavailable; using stripped git config fallback...');
|
|
1106
1114
|
}
|
|
1107
1115
|
}
|
|
1108
|
-
for (const { tool } of
|
|
1116
|
+
for (const { tool } of effectiveResolvedTools) {
|
|
1109
1117
|
for (const command of tool.postSetupCmds ?? []) {
|
|
1110
1118
|
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
|
|
1111
1119
|
}
|
|
@@ -1143,7 +1151,7 @@ export async function create(args) {
|
|
|
1143
1151
|
}
|
|
1144
1152
|
}
|
|
1145
1153
|
p.outro(pc.green('Sandbox ready'));
|
|
1146
|
-
const toolHints =
|
|
1154
|
+
const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
|
|
1147
1155
|
const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
|
|
1148
1156
|
const hint = hasLiveMount
|
|
1149
1157
|
? 'Live-mounted auth/config files stay in sync with the host.'
|
|
@@ -2,11 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import pc from 'picocolors';
|
|
5
6
|
import { validateSandboxEngine } from "./engine.js";
|
|
6
7
|
import { hostJoin } from "./engines/wsl2-paths.js";
|
|
8
|
+
import { findRuntimeEngineMismatches } from "./runtime-engines.js";
|
|
7
9
|
const DEFAULTS = Object.freeze({
|
|
8
10
|
engine: null,
|
|
9
|
-
runtimes: ['
|
|
11
|
+
runtimes: ['node22'],
|
|
10
12
|
tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
|
|
11
13
|
dockerfile: null,
|
|
12
14
|
vm: {
|
|
@@ -38,7 +40,7 @@ function cloneDefaults() {
|
|
|
38
40
|
vm: { ...DEFAULTS.vm }
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
|
-
export function loadConfig({ platformFn = platform } = {}) {
|
|
43
|
+
export function loadConfig({ platformFn = platform, writeStderr = (chunk) => process.stderr.write(chunk) } = {}) {
|
|
42
44
|
const repoRoot = detectRepoRoot();
|
|
43
45
|
const home = homedir();
|
|
44
46
|
if (!home) {
|
|
@@ -56,6 +58,24 @@ export function loadConfig({ platformFn = platform } = {}) {
|
|
|
56
58
|
if (!project || typeof project !== 'string') {
|
|
57
59
|
throw new Error('sandbox: .agents/.airc.json is missing a valid "project" field');
|
|
58
60
|
}
|
|
61
|
+
const runtimes = Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
|
|
62
|
+
? [...sandbox.runtimes]
|
|
63
|
+
: defaults.runtimes;
|
|
64
|
+
const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
|
|
65
|
+
if (!dockerfile) {
|
|
66
|
+
let enginesNode;
|
|
67
|
+
try {
|
|
68
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
69
|
+
enginesNode = typeof pkg.engines?.node === 'string' ? pkg.engines.node : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
enginesNode = undefined;
|
|
73
|
+
}
|
|
74
|
+
for (const { runtimes: invalidRuntimes, enginesNode: range } of findRuntimeEngineMismatches(runtimes, enginesNode)) {
|
|
75
|
+
writeStderr(pc.yellow(`Warning: sandbox runtimes ${invalidRuntimes.map((runtime) => `"${runtime}"`).join(', ')} do not satisfy this project's package.json "engines.node" ("${range}").\n` +
|
|
76
|
+
' Update "sandbox.runtimes" in .agents/.airc.json (e.g. "node22"), or relax "engines.node".\n'));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
59
79
|
return {
|
|
60
80
|
repoRoot,
|
|
61
81
|
configPath,
|
|
@@ -68,13 +88,11 @@ export function loadConfig({ platformFn = platform } = {}) {
|
|
|
68
88
|
shareBase: hostJoin(home, '.agent-infra', 'share', project),
|
|
69
89
|
dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
|
|
70
90
|
engine,
|
|
71
|
-
runtimes
|
|
72
|
-
? [...sandbox.runtimes]
|
|
73
|
-
: defaults.runtimes,
|
|
91
|
+
runtimes,
|
|
74
92
|
tools: Array.isArray(sandbox.tools) && sandbox.tools.length > 0
|
|
75
93
|
? [...sandbox.tools]
|
|
76
94
|
: defaults.tools,
|
|
77
|
-
dockerfile
|
|
95
|
+
dockerfile,
|
|
78
96
|
vm: {
|
|
79
97
|
cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
|
|
80
98
|
memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
|
|
@@ -395,43 +395,62 @@ export function formatRemaining(expiresAt) {
|
|
|
395
395
|
const minutes = totalMinutes % 60;
|
|
396
396
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
397
397
|
}
|
|
398
|
-
export function
|
|
398
|
+
export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
|
|
399
399
|
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
400
400
|
if (!claudeCodeEntry) {
|
|
401
|
-
return;
|
|
401
|
+
return { status: 'NOT_APPLICABLE' };
|
|
402
402
|
}
|
|
403
403
|
let blob = null;
|
|
404
404
|
const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
|
|
405
405
|
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
|
|
406
406
|
if (hasCustomInspectFn || !hasCustomExtractFn) {
|
|
407
407
|
const inspection = inspectFn(home);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
408
|
+
switch (inspection.status) {
|
|
409
|
+
case 'OK':
|
|
410
|
+
blob = inspection.blob;
|
|
411
|
+
break;
|
|
412
|
+
case 'MISSING':
|
|
413
|
+
return { status: 'SKIPPED' };
|
|
414
|
+
case 'KEYCHAIN_LOCKED':
|
|
415
|
+
throw new Error([
|
|
416
|
+
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
|
|
417
|
+
'',
|
|
418
|
+
buildLockedGuidance()
|
|
419
|
+
].join('\n'));
|
|
420
|
+
case 'STALE_ACCESS':
|
|
421
|
+
throw new Error([
|
|
422
|
+
'Claude Code credentials on host are invalid or expired.',
|
|
423
|
+
'',
|
|
424
|
+
'The sandbox needs valid Claude Code OAuth credentials so the container can use Claude Code.',
|
|
425
|
+
'',
|
|
426
|
+
'To fix:',
|
|
427
|
+
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
428
|
+
' 2. Verify with "claude /status" that you see your subscription.',
|
|
429
|
+
' 3. Re-run "ai sandbox create".'
|
|
430
|
+
].join('\n'));
|
|
431
|
+
case 'KEYCHAIN_ERROR':
|
|
432
|
+
throw new Error([
|
|
433
|
+
'Claude Code credentials could not be read from the host keychain.',
|
|
434
|
+
'',
|
|
435
|
+
inspection.detail ? `Detail: ${inspection.detail}` : 'Detail: unknown keychain error',
|
|
436
|
+
'',
|
|
437
|
+
'To fix:',
|
|
438
|
+
' 1. Unlock or repair the host keychain, then re-run "ai sandbox create".',
|
|
439
|
+
' 2. For SSH / CI, set AGENT_INFRA_CLAUDE_CREDENTIALS_FILE to an absolute path containing a valid credentials blob.'
|
|
440
|
+
].join('\n'));
|
|
441
|
+
default: {
|
|
442
|
+
const _exhaustive = inspection;
|
|
443
|
+
throw new Error(`Unhandled Claude Code credential inspection status: ${_exhaustive.status}`);
|
|
444
|
+
}
|
|
414
445
|
}
|
|
415
|
-
blob = inspection.status === 'OK' ? inspection.blob : null;
|
|
416
446
|
}
|
|
417
447
|
else {
|
|
418
448
|
blob = extractFn(home);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
'Claude Code credentials not found on host.',
|
|
423
|
-
'',
|
|
424
|
-
'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
|
|
425
|
-
'',
|
|
426
|
-
'To fix:',
|
|
427
|
-
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
428
|
-
' 2. Verify with "claude /status" that you see your subscription.',
|
|
429
|
-
' 3. Re-run "ai sandbox create".',
|
|
430
|
-
'',
|
|
431
|
-
'Alternatively, if you do not need Claude Code in this sandbox,',
|
|
432
|
-
'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
|
|
433
|
-
].join('\n'));
|
|
449
|
+
if (!blob) {
|
|
450
|
+
return { status: 'SKIPPED' };
|
|
451
|
+
}
|
|
434
452
|
}
|
|
435
453
|
writeFn(home, project, blob);
|
|
454
|
+
return { status: 'OK' };
|
|
436
455
|
}
|
|
437
456
|
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
function nodeMajor(runtime) {
|
|
3
|
+
const match = /^node(\d+)$/.exec(runtime);
|
|
4
|
+
return match ? Number(match[1]) : null;
|
|
5
|
+
}
|
|
6
|
+
export function findRuntimeEngineMismatches(runtimes, enginesNode) {
|
|
7
|
+
if (!enginesNode) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const range = semver.validRange(enginesNode);
|
|
11
|
+
if (!range) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
const nodeRuntimes = [];
|
|
15
|
+
for (const runtime of runtimes) {
|
|
16
|
+
const major = nodeMajor(runtime);
|
|
17
|
+
if (major === null) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
nodeRuntimes.push(runtime);
|
|
21
|
+
if (semver.intersects(`${major}.x`, range)) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return nodeRuntimes.length > 0 ? [{ runtimes: nodeRuntimes, enginesNode }] : [];
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=runtime-engines.js.map
|
package/dist/package.json
CHANGED
package/lib/defaults.json
CHANGED
|
@@ -43,7 +43,7 @@ import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
|
|
|
43
43
|
import { resolveBuildUid } from '../engines/native.ts';
|
|
44
44
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
45
45
|
import {
|
|
46
|
-
|
|
46
|
+
prepareClaudeCredentials,
|
|
47
47
|
redactCommandError,
|
|
48
48
|
validateClaudeCredentialsEnvOverride
|
|
49
49
|
} from '../credentials.ts';
|
|
@@ -1100,15 +1100,17 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1100
1100
|
assertBranchAvailable(config.repoRoot, branch, { allowedWorktrees: worktreeCandidates });
|
|
1101
1101
|
const tools = resolveTools(effectiveConfig);
|
|
1102
1102
|
const resolvedTools = resolveToolDirs(effectiveConfig, tools, branch);
|
|
1103
|
-
//
|
|
1104
|
-
// Claude Code credential
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
assertClaudeCredentialsAvailable(
|
|
1103
|
+
// Fatal credential states still fail before filesystem/docker side effects.
|
|
1104
|
+
// A genuinely missing Claude Code credential only removes Claude Code's
|
|
1105
|
+
// sandbox config and credential mounts for this create run.
|
|
1106
|
+
const credentialOutcome = prepareClaudeCredentials(
|
|
1108
1107
|
effectiveConfig.home,
|
|
1109
1108
|
effectiveConfig.project,
|
|
1110
1109
|
resolvedTools
|
|
1111
1110
|
);
|
|
1111
|
+
const effectiveResolvedTools = credentialOutcome.status === 'SKIPPED'
|
|
1112
|
+
? resolvedTools.filter(({ tool }) => tool.id !== 'claude-code')
|
|
1113
|
+
: resolvedTools;
|
|
1112
1114
|
const container = containerName(effectiveConfig, branch);
|
|
1113
1115
|
const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
|
|
1114
1116
|
const shareCommon = shareCommonDir(effectiveConfig);
|
|
@@ -1122,6 +1124,13 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1122
1124
|
p.log.info(
|
|
1123
1125
|
`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`
|
|
1124
1126
|
);
|
|
1127
|
+
if (credentialOutcome.status === 'SKIPPED') {
|
|
1128
|
+
p.log.warn(
|
|
1129
|
+
'Claude Code credentials not found on host - creating this sandbox WITHOUT Claude Code credentials.\n'
|
|
1130
|
+
+ ' Claude Code is still installed in the image but will not be authenticated.\n'
|
|
1131
|
+
+ ' To enable it: run "claude" once on the host to complete login, then re-run "ai sandbox create".'
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1125
1134
|
|
|
1126
1135
|
try {
|
|
1127
1136
|
p.log.step('Checking container engine...');
|
|
@@ -1206,7 +1215,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1206
1215
|
{
|
|
1207
1216
|
title: 'Preparing tool state',
|
|
1208
1217
|
task: async () => {
|
|
1209
|
-
for (const { tool, dir } of
|
|
1218
|
+
for (const { tool, dir } of effectiveResolvedTools) {
|
|
1210
1219
|
fs.mkdirSync(dir, { recursive: true });
|
|
1211
1220
|
|
|
1212
1221
|
for (const { hostPath, sandboxName } of tool.hostPreSeedFiles ?? []) {
|
|
@@ -1243,7 +1252,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1243
1252
|
}
|
|
1244
1253
|
}
|
|
1245
1254
|
|
|
1246
|
-
return `${
|
|
1255
|
+
return `${effectiveResolvedTools.length} tool config directories ready`;
|
|
1247
1256
|
}
|
|
1248
1257
|
},
|
|
1249
1258
|
{
|
|
@@ -1283,31 +1292,32 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1283
1292
|
signingKey
|
|
1284
1293
|
)
|
|
1285
1294
|
: null;
|
|
1286
|
-
const envFile = buildContainerEnvFile(
|
|
1295
|
+
const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
|
|
1287
1296
|
let hostShellConfig: HostShellConfig;
|
|
1288
1297
|
try {
|
|
1289
|
-
const claudeCodeEntry =
|
|
1298
|
+
const claudeCodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
1290
1299
|
if (claudeCodeEntry) {
|
|
1291
1300
|
ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1292
1301
|
ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1302
|
+
// prepareClaudeCredentials wrote the shared credentials file
|
|
1303
|
+
// before this point. If credentials were missing, the
|
|
1304
|
+
// claude-code entry was removed from effectiveResolvedTools.
|
|
1295
1305
|
}
|
|
1296
|
-
const codexEntry =
|
|
1306
|
+
const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
|
|
1297
1307
|
if (codexEntry) {
|
|
1298
1308
|
ensureCodexModelInheritance(codexEntry.dir, effectiveConfig.home);
|
|
1299
1309
|
ensureCodexWorkspaceTrust(codexEntry.dir);
|
|
1300
1310
|
}
|
|
1301
|
-
const geminiEntry =
|
|
1311
|
+
const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
|
|
1302
1312
|
if (geminiEntry) {
|
|
1303
1313
|
ensureGeminiWorkspaceTrust(geminiEntry.dir);
|
|
1304
1314
|
}
|
|
1305
|
-
const opencodeEntry =
|
|
1315
|
+
const opencodeEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'opencode');
|
|
1306
1316
|
if (opencodeEntry) {
|
|
1307
1317
|
// The TUI reads <toolDir>/opencode.json via OPENCODE_CONFIG pinned in tools.js.
|
|
1308
1318
|
ensureOpenCodeModelInheritance(opencodeEntry.dir, effectiveConfig.home);
|
|
1309
1319
|
}
|
|
1310
|
-
const toolVolumes =
|
|
1320
|
+
const toolVolumes = effectiveResolvedTools.flatMap(({ tool, dir }) => [
|
|
1311
1321
|
'-v',
|
|
1312
1322
|
volumeArg(engine, dir, tool.containerMount)
|
|
1313
1323
|
]);
|
|
@@ -1322,7 +1332,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1322
1332
|
'-v',
|
|
1323
1333
|
volumeArg(engine, hostPath, containerPath, ':ro')
|
|
1324
1334
|
]);
|
|
1325
|
-
const liveMountVolumes =
|
|
1335
|
+
const liveMountVolumes = effectiveResolvedTools.flatMap(({ tool }) =>
|
|
1326
1336
|
(tool.hostLiveMounts ?? [])
|
|
1327
1337
|
.filter(({ hostPath }) => fs.existsSync(hostPath))
|
|
1328
1338
|
.flatMap(({ hostPath, containerSubpath }) => [
|
|
@@ -1435,7 +1445,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1435
1445
|
}
|
|
1436
1446
|
}
|
|
1437
1447
|
|
|
1438
|
-
for (const { tool } of
|
|
1448
|
+
for (const { tool } of effectiveResolvedTools) {
|
|
1439
1449
|
for (const command of tool.postSetupCmds ?? []) {
|
|
1440
1450
|
runSafeEngine(engine, 'docker', ['exec', container, 'bash', '-lc', command]);
|
|
1441
1451
|
}
|
|
@@ -1477,7 +1487,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1477
1487
|
|
|
1478
1488
|
p.outro(pc.green('Sandbox ready'));
|
|
1479
1489
|
|
|
1480
|
-
const toolHints =
|
|
1490
|
+
const toolHints = effectiveResolvedTools.map(({ tool, dir }) => {
|
|
1481
1491
|
const hasLiveMount = (tool.hostLiveMounts ?? []).some(({ hostPath }) => fs.existsSync(hostPath));
|
|
1482
1492
|
const hint = hasLiveMount
|
|
1483
1493
|
? 'Live-mounted auth/config files stay in sync with the host.'
|
package/lib/sandbox/config.ts
CHANGED
|
@@ -2,12 +2,14 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import pc from 'picocolors';
|
|
5
6
|
import { validateSandboxEngine } from './engine.ts';
|
|
6
7
|
import { hostJoin } from './engines/wsl2-paths.ts';
|
|
8
|
+
import { findRuntimeEngineMismatches } from './runtime-engines.ts';
|
|
7
9
|
|
|
8
10
|
const DEFAULTS = Object.freeze({
|
|
9
11
|
engine: null,
|
|
10
|
-
runtimes: ['
|
|
12
|
+
runtimes: ['node22'],
|
|
11
13
|
tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
|
|
12
14
|
dockerfile: null,
|
|
13
15
|
vm: {
|
|
@@ -18,6 +20,7 @@ const DEFAULTS = Object.freeze({
|
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
type PlatformFn = typeof platform;
|
|
23
|
+
type WriteStderr = (chunk: string) => unknown;
|
|
21
24
|
|
|
22
25
|
type SandboxConfigInput = {
|
|
23
26
|
engine?: string | null;
|
|
@@ -82,7 +85,10 @@ function cloneDefaults(): SandboxConfigInput & { vm: SandboxVmConfig; runtimes:
|
|
|
82
85
|
};
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
export function loadConfig({
|
|
88
|
+
export function loadConfig({
|
|
89
|
+
platformFn = platform,
|
|
90
|
+
writeStderr = (chunk) => process.stderr.write(chunk)
|
|
91
|
+
}: { platformFn?: PlatformFn; writeStderr?: WriteStderr } = {}): SandboxConfig {
|
|
86
92
|
const repoRoot = detectRepoRoot();
|
|
87
93
|
const home = homedir();
|
|
88
94
|
|
|
@@ -105,6 +111,30 @@ export function loadConfig({ platformFn = platform }: { platformFn?: PlatformFn
|
|
|
105
111
|
throw new Error('sandbox: .agents/.airc.json is missing a valid "project" field');
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
const runtimes = Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
|
|
115
|
+
? [...sandbox.runtimes]
|
|
116
|
+
: defaults.runtimes;
|
|
117
|
+
const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
|
|
118
|
+
|
|
119
|
+
if (!dockerfile) {
|
|
120
|
+
let enginesNode: string | undefined;
|
|
121
|
+
try {
|
|
122
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
|
|
123
|
+
engines?: { node?: unknown };
|
|
124
|
+
};
|
|
125
|
+
enginesNode = typeof pkg.engines?.node === 'string' ? pkg.engines.node : undefined;
|
|
126
|
+
} catch {
|
|
127
|
+
enginesNode = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const { runtimes: invalidRuntimes, enginesNode: range } of findRuntimeEngineMismatches(runtimes, enginesNode)) {
|
|
131
|
+
writeStderr(pc.yellow(
|
|
132
|
+
`Warning: sandbox runtimes ${invalidRuntimes.map((runtime) => `"${runtime}"`).join(', ')} do not satisfy this project's package.json "engines.node" ("${range}").\n` +
|
|
133
|
+
' Update "sandbox.runtimes" in .agents/.airc.json (e.g. "node22"), or relax "engines.node".\n'
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
return {
|
|
109
139
|
repoRoot,
|
|
110
140
|
configPath,
|
|
@@ -117,13 +147,11 @@ export function loadConfig({ platformFn = platform }: { platformFn?: PlatformFn
|
|
|
117
147
|
shareBase: hostJoin(home, '.agent-infra', 'share', project),
|
|
118
148
|
dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
|
|
119
149
|
engine,
|
|
120
|
-
runtimes
|
|
121
|
-
? [...sandbox.runtimes]
|
|
122
|
-
: defaults.runtimes,
|
|
150
|
+
runtimes,
|
|
123
151
|
tools: Array.isArray(sandbox.tools) && sandbox.tools.length > 0
|
|
124
152
|
? [...sandbox.tools]
|
|
125
153
|
: defaults.tools,
|
|
126
|
-
dockerfile
|
|
154
|
+
dockerfile,
|
|
127
155
|
vm: {
|
|
128
156
|
cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
|
|
129
157
|
memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
|
|
@@ -92,6 +92,11 @@ type ResolvedTool = {
|
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
export type ClaudeCredentialOutcome =
|
|
96
|
+
| { status: 'OK' }
|
|
97
|
+
| { status: 'SKIPPED' }
|
|
98
|
+
| { status: 'NOT_APPLICABLE' };
|
|
99
|
+
|
|
95
100
|
type CredentialPayload = {
|
|
96
101
|
claudeAiOauth?: CredentialPayload;
|
|
97
102
|
scopes?: unknown;
|
|
@@ -584,17 +589,17 @@ export function formatRemaining(expiresAt: unknown): string {
|
|
|
584
589
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
585
590
|
}
|
|
586
591
|
|
|
587
|
-
export function
|
|
592
|
+
export function prepareClaudeCredentials(
|
|
588
593
|
home: string,
|
|
589
594
|
project: string,
|
|
590
595
|
resolvedTools: ResolvedTool[],
|
|
591
596
|
extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
|
|
592
597
|
writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
|
|
593
598
|
inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
|
|
594
|
-
):
|
|
599
|
+
): ClaudeCredentialOutcome {
|
|
595
600
|
const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
|
|
596
601
|
if (!claudeCodeEntry) {
|
|
597
|
-
return;
|
|
602
|
+
return { status: 'NOT_APPLICABLE' };
|
|
598
603
|
}
|
|
599
604
|
|
|
600
605
|
let blob: string | null = null;
|
|
@@ -602,33 +607,51 @@ export function assertClaudeCredentialsAvailable(
|
|
|
602
607
|
const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
|
|
603
608
|
if (hasCustomInspectFn || !hasCustomExtractFn) {
|
|
604
609
|
const inspection = inspectFn(home);
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
610
|
+
switch (inspection.status) {
|
|
611
|
+
case 'OK':
|
|
612
|
+
blob = inspection.blob;
|
|
613
|
+
break;
|
|
614
|
+
case 'MISSING':
|
|
615
|
+
return { status: 'SKIPPED' };
|
|
616
|
+
case 'KEYCHAIN_LOCKED':
|
|
617
|
+
throw new Error([
|
|
618
|
+
'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
|
|
619
|
+
'',
|
|
620
|
+
buildLockedGuidance()
|
|
621
|
+
].join('\n'));
|
|
622
|
+
case 'STALE_ACCESS':
|
|
623
|
+
throw new Error([
|
|
624
|
+
'Claude Code credentials on host are invalid or expired.',
|
|
625
|
+
'',
|
|
626
|
+
'The sandbox needs valid Claude Code OAuth credentials so the container can use Claude Code.',
|
|
627
|
+
'',
|
|
628
|
+
'To fix:',
|
|
629
|
+
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
630
|
+
' 2. Verify with "claude /status" that you see your subscription.',
|
|
631
|
+
' 3. Re-run "ai sandbox create".'
|
|
632
|
+
].join('\n'));
|
|
633
|
+
case 'KEYCHAIN_ERROR':
|
|
634
|
+
throw new Error([
|
|
635
|
+
'Claude Code credentials could not be read from the host keychain.',
|
|
636
|
+
'',
|
|
637
|
+
inspection.detail ? `Detail: ${inspection.detail}` : 'Detail: unknown keychain error',
|
|
638
|
+
'',
|
|
639
|
+
'To fix:',
|
|
640
|
+
' 1. Unlock or repair the host keychain, then re-run "ai sandbox create".',
|
|
641
|
+
' 2. For SSH / CI, set AGENT_INFRA_CLAUDE_CREDENTIALS_FILE to an absolute path containing a valid credentials blob.'
|
|
642
|
+
].join('\n'));
|
|
643
|
+
default: {
|
|
644
|
+
const _exhaustive: never = inspection;
|
|
645
|
+
throw new Error(`Unhandled Claude Code credential inspection status: ${(_exhaustive as { status: string }).status}`);
|
|
646
|
+
}
|
|
611
647
|
}
|
|
612
|
-
blob = inspection.status === 'OK' ? inspection.blob : null;
|
|
613
648
|
} else {
|
|
614
649
|
blob = extractFn(home);
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
throw new Error([
|
|
619
|
-
'Claude Code credentials not found on host.',
|
|
620
|
-
'',
|
|
621
|
-
'The sandbox needs your Claude Code OAuth credentials so the container can use Claude Code.',
|
|
622
|
-
'',
|
|
623
|
-
'To fix:',
|
|
624
|
-
' 1. On the host, run "claude" once and complete the OAuth login flow.',
|
|
625
|
-
' 2. Verify with "claude /status" that you see your subscription.',
|
|
626
|
-
' 3. Re-run "ai sandbox create".',
|
|
627
|
-
'',
|
|
628
|
-
'Alternatively, if you do not need Claude Code in this sandbox,',
|
|
629
|
-
'remove "claude-code" from the "sandbox.tools" array in .agents/.airc.json.'
|
|
630
|
-
].join('\n'));
|
|
650
|
+
if (!blob) {
|
|
651
|
+
return { status: 'SKIPPED' };
|
|
652
|
+
}
|
|
631
653
|
}
|
|
632
654
|
|
|
633
655
|
writeFn(home, project, blob);
|
|
656
|
+
return { status: 'OK' };
|
|
634
657
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
|
|
3
|
+
export type RuntimeEngineMismatch = {
|
|
4
|
+
runtimes: string[];
|
|
5
|
+
enginesNode: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function nodeMajor(runtime: string): number | null {
|
|
9
|
+
const match = /^node(\d+)$/.exec(runtime);
|
|
10
|
+
return match ? Number(match[1]) : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findRuntimeEngineMismatches(
|
|
14
|
+
runtimes: string[],
|
|
15
|
+
enginesNode: string | undefined
|
|
16
|
+
): RuntimeEngineMismatch[] {
|
|
17
|
+
if (!enginesNode) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const range = semver.validRange(enginesNode);
|
|
22
|
+
if (!range) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nodeRuntimes: string[] = [];
|
|
27
|
+
for (const runtime of runtimes) {
|
|
28
|
+
const major = nodeMajor(runtime);
|
|
29
|
+
if (major === null) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
nodeRuntimes.push(runtime);
|
|
33
|
+
if (semver.intersects(`${major}.x`, range)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return nodeRuntimes.length > 0 ? [{ runtimes: nodeRuntimes, enginesNode }] : [];
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitlab-ai/agent-infra",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@clack/prompts": "1.4.0",
|
|
47
47
|
"cross-spawn": "^7.0.6",
|
|
48
48
|
"picocolors": "1.1.1",
|
|
49
|
+
"semver": "^7.8.1",
|
|
49
50
|
"smol-toml": "^1.6.1"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/cross-spawn": "^6.0.6",
|
|
63
64
|
"@types/node": "^25.9.1",
|
|
65
|
+
"@types/semver": "^7.7.1",
|
|
64
66
|
"typescript": "~6.0"
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -149,13 +149,13 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
|
|
|
149
149
|
|
|
150
150
|
Failure is non-blocking.
|
|
151
151
|
|
|
152
|
-
###
|
|
152
|
+
### 7. Set Issue Fields (Optional)
|
|
153
153
|
|
|
154
154
|
If `has_push=true`, read `.agents/rules/issue-fields.md` and follow Flow A to write any applicable non-empty `priority`, `effort`, `start_date`, and `target_date` values from `task.md`.
|
|
155
155
|
|
|
156
156
|
Field write failures are non-blocking.
|
|
157
157
|
|
|
158
|
-
###
|
|
158
|
+
### 8. Write Back task.md
|
|
159
159
|
|
|
160
160
|
Update task.md:
|
|
161
161
|
|
|
@@ -164,7 +164,7 @@ Update task.md:
|
|
|
164
164
|
|
|
165
165
|
> Do NOT append an Activity Log entry here. The Issue creation event is already captured by the GitHub Issue itself and by the frontmatter `issue_number` field; the Activity Log only records the single `create-task` skill execution anchor (`Task Created`), written by the caller SKILL step 3.
|
|
166
166
|
|
|
167
|
-
###
|
|
167
|
+
### 9. Return the Result
|
|
168
168
|
|
|
169
169
|
Hand the following back to the caller `create-task`:
|
|
170
170
|
|
|
@@ -149,13 +149,13 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
|
|
|
149
149
|
|
|
150
150
|
设置失败不阻断流程。
|
|
151
151
|
|
|
152
|
-
###
|
|
152
|
+
### 7. 设置 Issue 字段(可选)
|
|
153
153
|
|
|
154
154
|
如果 `has_push=true`,读取 `.agents/rules/issue-fields.md`,按流程 A 写入 `task.md` 中适用且非空的 `priority`、`effort`、`start_date` 和 `target_date`。
|
|
155
155
|
|
|
156
156
|
字段写入失败不阻断流程。
|
|
157
157
|
|
|
158
|
-
###
|
|
158
|
+
### 8. 回写 task.md
|
|
159
159
|
|
|
160
160
|
更新 task.md:
|
|
161
161
|
|
|
@@ -164,7 +164,7 @@ gh api "repos/$upstream_repo/issues/{issue-number}" -X PATCH \
|
|
|
164
164
|
|
|
165
165
|
> 不要在此追加 Activity Log 条目。Issue 创建事件已由 GitHub Issue 自身和 frontmatter `issue_number` 承载;Activity Log 仅记录 `create-task` skill 一次执行的整体锚点(`Task Created`),由调用方 SKILL 步骤 3 写入。
|
|
166
166
|
|
|
167
|
-
###
|
|
167
|
+
### 9. 返回结果
|
|
168
168
|
|
|
169
169
|
把以下信息回传给调用方 `create-task`:
|
|
170
170
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Release Platform Commands
|
|
2
2
|
|
|
3
|
-
Read this file before loading release history, querying merged PRs, or
|
|
3
|
+
Read this file before loading release history, querying merged PRs, or publishing the Release notes.
|
|
4
4
|
|
|
5
5
|
## Query Releases
|
|
6
6
|
|
|
@@ -37,10 +37,16 @@ gh issue view {issue-number} --json number,title,labels,url,author
|
|
|
37
37
|
|
|
38
38
|
Map GitHub no-reply emails with this rule: if `Name <email>` contains an email matching `(\d+\+)?(\S+?)@users\.noreply\.github\.com`, use the second capture group lowercased as the login. This covers both `{id}+{login}@users.noreply.github.com` and `{login}@users.noreply.github.com`.
|
|
39
39
|
|
|
40
|
-
##
|
|
40
|
+
## Publish the Release Notes
|
|
41
|
+
|
|
42
|
+
The GitHub Release for `v{version}` is created and published automatically by the release workflow so Homebrew bottles have a stable upload target. This command writes the curated notes onto that existing Release, falling back to creating it if it does not exist yet.
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
|
-
gh release
|
|
45
|
+
if gh release view "v{version}" >/dev/null 2>&1; then
|
|
46
|
+
gh release edit "v{version}" --notes-file "{notes-file}"
|
|
47
|
+
else
|
|
48
|
+
gh release create "v{version}" --title "v{version}" --notes-file "{notes-file}"
|
|
49
|
+
fi
|
|
44
50
|
```
|
|
45
51
|
|
|
46
52
|
If commands fail, stop or escalate according to the calling skill.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Release 平台命令
|
|
2
2
|
|
|
3
|
-
在读取历史 release、查询已合并 PR
|
|
3
|
+
在读取历史 release、查询已合并 PR,或发布 Release notes 前先读取本文件。
|
|
4
4
|
|
|
5
5
|
## Release 查询
|
|
6
6
|
|
|
@@ -37,10 +37,16 @@ gh issue view {issue-number} --json number,title,labels,url,author
|
|
|
37
37
|
|
|
38
38
|
GitHub no-reply 邮箱映射规则:如果 `Name <email>` 中的 email 匹配 `(\d+\+)?(\S+?)@users\.noreply\.github\.com`,使用第二个捕获组的小写形式作为 login。该规则同时覆盖 `{id}+{login}@users.noreply.github.com` 和 `{login}@users.noreply.github.com`。
|
|
39
39
|
|
|
40
|
-
##
|
|
40
|
+
## 发布 Release notes
|
|
41
|
+
|
|
42
|
+
`v{version}` 的 GitHub Release 由 release 工作流自动创建并发布,为 Homebrew bottle 提供稳定的上传落点。本命令把精修后的 notes 写到这个已存在的 Release 上;若 Release 尚不存在则兜底创建。
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
|
-
gh release
|
|
45
|
+
if gh release view "v{version}" >/dev/null 2>&1; then
|
|
46
|
+
gh release edit "v{version}" --notes-file "{notes-file}"
|
|
47
|
+
else
|
|
48
|
+
gh release create "v{version}" --title "v{version}" --notes-file "{notes-file}"
|
|
49
|
+
fi
|
|
44
50
|
```
|
|
45
51
|
|
|
46
52
|
失败时按调用方规则停止或提示人工介入。
|
|
@@ -49,6 +49,8 @@ If `task.md` contains these source fields, also read the corresponding source in
|
|
|
49
49
|
|
|
50
50
|
### 4. Perform Requirements Analysis
|
|
51
51
|
|
|
52
|
+
Before analysis begins: if `start_date` in the frontmatter is empty, write today's date immediately (command: `date +%F`, format `YYYY-MM-DD`); keep any existing value. Before writing, read `.agents/rules/version-stamp.md` and refresh `updated_at` / `agent_infra_version` at the same time.
|
|
53
|
+
|
|
52
54
|
Follow the `analysis` step in `.agents/workflows/feature-development.yaml`:
|
|
53
55
|
|
|
54
56
|
**Required tasks** (analysis only, no business code changes):
|
|
@@ -126,6 +128,7 @@ If task.md contains a valid `issue_number`, perform these sync actions (skip and
|
|
|
126
128
|
- Set `status: pending-design-work` by following issue-sync.md
|
|
127
129
|
- Create or update the task comment marker defined in `.agents/rules/issue-sync.md` (follow the task.md comment sync rule in issue-sync.md)
|
|
128
130
|
- Publish the `{analysis-artifact}` comment
|
|
131
|
+
- Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
|
|
129
132
|
|
|
130
133
|
### 7. Verification Gate
|
|
131
134
|
|
|
@@ -49,6 +49,8 @@ description: "分析任务并输出需求分析文档"
|
|
|
49
49
|
|
|
50
50
|
### 4. 执行需求分析
|
|
51
51
|
|
|
52
|
+
开始分析前:若 frontmatter 的 `start_date` 为空,立即写入当日日期(命令 `date +%F`,格式 `YYYY-MM-DD`);已有值则保留。写入前先读取 `.agents/rules/version-stamp.md`,并同步刷新 `updated_at` / `agent_infra_version`。
|
|
53
|
+
|
|
52
54
|
遵循 `.agents/workflows/feature-development.yaml` 中的 `analysis` 步骤:
|
|
53
55
|
|
|
54
56
|
**必要任务**(仅分析,不编写业务代码):
|
|
@@ -126,6 +128,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
|
|
|
126
128
|
- 按 issue-sync.md 设置 `status: pending-design-work`
|
|
127
129
|
- 创建或更新 `.agents/rules/issue-sync.md` 中定义的 task 评论标记(按 issue-sync.md 的 task.md 评论同步规则)
|
|
128
130
|
- 发布 `{analysis-artifact}` 评论
|
|
131
|
+
- 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
|
|
129
132
|
|
|
130
133
|
### 7. 完成校验
|
|
131
134
|
|
|
@@ -98,6 +98,7 @@ If a valid `issue_number` exists:
|
|
|
98
98
|
- Backfill checked `## Requirements` items to the Issue body by following the requirements-checkbox sync steps in issue-sync.md
|
|
99
99
|
- Do not set any `status:` label — status labels are automatically cleared when the Issue is closed
|
|
100
100
|
- Finally create or update the summary comment marked with the summary marker defined in `.agents/rules/issue-sync.md`
|
|
101
|
+
- Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
|
|
101
102
|
|
|
102
103
|
### 7. Verification Gate
|
|
103
104
|
|
|
@@ -98,6 +98,7 @@ ls .agents/workspace/completed/{task-id}/task.md
|
|
|
98
98
|
- 按 issue-sync.md 的需求复选框同步步骤,兜底同步 `## 需求` 中已勾选的条目到 Issue body
|
|
99
99
|
- 不要设置 `status:` label — Issue 关闭后 status label 会被自动清除
|
|
100
100
|
- 最后创建或更新 `.agents/rules/issue-sync.md` 中定义的 summary 评论标记对应的 summary 评论
|
|
101
|
+
- 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
|
|
101
102
|
|
|
102
103
|
### 7. 完成校验
|
|
103
104
|
|
|
@@ -156,31 +156,28 @@ Show the generated release notes to the user.
|
|
|
156
156
|
|
|
157
157
|
Ask:
|
|
158
158
|
1. Need any adjustments?
|
|
159
|
-
2.
|
|
159
|
+
2. Write these notes onto the Release for this version?
|
|
160
160
|
|
|
161
|
-
### 9.
|
|
161
|
+
### 9. Publish the Release Notes (If Confirmed)
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
Write the notes by following the "Publish the Release Notes" command in `.agents/rules/release-commands.md` (it updates the Release already created and published by the release workflow, falling back to creating it if missing).
|
|
164
164
|
|
|
165
165
|
Output:
|
|
166
166
|
```
|
|
167
|
-
|
|
167
|
+
Release notes updated.
|
|
168
168
|
|
|
169
|
-
- URL: {
|
|
169
|
+
- URL: {release-url}
|
|
170
170
|
- Version: v{version}
|
|
171
|
-
- Status:
|
|
171
|
+
- Status: Published
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
1. Open the URL above
|
|
175
|
-
2. Review the release notes
|
|
176
|
-
3. Click "Publish release"
|
|
173
|
+
The notes have been written to the Release. Edit further at the URL above if needed.
|
|
177
174
|
```
|
|
178
175
|
|
|
179
176
|
## Notes
|
|
180
177
|
|
|
181
178
|
1. **Requires the platform CLI**: Must have the platform CLI installed and authenticated
|
|
182
179
|
2. **Tags must exist**: Run the release skill first to create tags
|
|
183
|
-
3. **
|
|
180
|
+
3. **Release auto-published**: the `v{version}` Release is created and published by the release workflow (the upload target for Homebrew bottles); this skill writes/refreshes the notes on that Release
|
|
184
181
|
4. **Classification accuracy**: Auto-classification is based on title/scope/files; complex PRs may need manual adjustment
|
|
185
182
|
|
|
186
183
|
## Error Handling
|
|
@@ -156,31 +156,28 @@ git log v<prev-version>..v<version> \
|
|
|
156
156
|
|
|
157
157
|
询问:
|
|
158
158
|
1. 需要调整吗?
|
|
159
|
-
2.
|
|
159
|
+
2. 是否把 notes 写入该版本的 Release?
|
|
160
160
|
|
|
161
|
-
### 9.
|
|
161
|
+
### 9. 发布 Release notes(如确认)
|
|
162
162
|
|
|
163
|
-
按 `.agents/rules/release-commands.md`
|
|
163
|
+
按 `.agents/rules/release-commands.md` 的「发布 Release notes」命令执行(写入已由 release 工作流自动创建/发布的 Release;不存在时兜底创建)。
|
|
164
164
|
|
|
165
165
|
输出:
|
|
166
166
|
```
|
|
167
|
-
|
|
167
|
+
Release notes 已更新。
|
|
168
168
|
|
|
169
|
-
- URL: {
|
|
169
|
+
- URL: {release-url}
|
|
170
170
|
- Version: v{version}
|
|
171
|
-
- Status:
|
|
171
|
+
- Status: Published
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
1. Open the URL above
|
|
175
|
-
2. Review the release notes
|
|
176
|
-
3. Click "Publish release"
|
|
173
|
+
发布说明已写入该 Release。如需进一步调整,可在上面的 URL 直接编辑。
|
|
177
174
|
```
|
|
178
175
|
|
|
179
176
|
## 注意事项
|
|
180
177
|
|
|
181
178
|
1. **需要 the platform CLI**:必须安装并认证 the platform CLI
|
|
182
179
|
2. **标签必须存在**:先执行 release 技能创建标签
|
|
183
|
-
3.
|
|
180
|
+
3. **Release 已自动发布**:`v{version}` 的 Release 由 release 工作流自动创建并发布(给 Homebrew bottle 提供上传落点);本技能往该 Release 写入/刷新 notes
|
|
184
181
|
4. **分类准确性**:自动分类基于标题/scope/文件;复杂的 PR 可能需要手动调整
|
|
185
182
|
|
|
186
183
|
## 错误处理
|
|
@@ -78,8 +78,8 @@ created_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
|
|
|
78
78
|
updated_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
|
|
79
79
|
agent_infra_version: {agent_infra_version}
|
|
80
80
|
created_by: human
|
|
81
|
-
priority: #
|
|
82
|
-
effort: #
|
|
81
|
+
priority: # required; inferred by the AI from the title/description; Urgent | High | Medium | Low
|
|
82
|
+
effort: # required; inferred by the AI from the title/description; High | Medium | Low
|
|
83
83
|
start_date: # optional; YYYY-MM-DD
|
|
84
84
|
target_date: # optional; YYYY-MM-DD
|
|
85
85
|
current_step: requirement-analysis
|
|
@@ -87,7 +87,7 @@ assigned_to: {current AI agent}
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Note: `created_by` is `human` because the task comes from the user's description.
|
|
90
|
-
|
|
90
|
+
priority / effort are required: the AI infers them from the task title and description (candidates in `.agents/rules/issue-fields.md`; normalize localized input). Leave start_date / target_date empty at creation - analyze-task / plan-task fill them later; do not invent dates.
|
|
91
91
|
|
|
92
92
|
### 3. Update Task Status
|
|
93
93
|
|
|
@@ -78,8 +78,8 @@ created_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
|
|
|
78
78
|
updated_at: {YYYY-MM-DD HH:mm:ss±HH:MM}
|
|
79
79
|
agent_infra_version: {agent_infra_version}
|
|
80
80
|
created_by: human
|
|
81
|
-
priority: #
|
|
82
|
-
effort: #
|
|
81
|
+
priority: # 必填;由 AI 从标题/描述推断;Urgent | High | Medium | Low
|
|
82
|
+
effort: # 必填;由 AI 从标题/描述推断;High | Medium | Low
|
|
83
83
|
start_date: # 可选;YYYY-MM-DD
|
|
84
84
|
target_date: # 可选;YYYY-MM-DD
|
|
85
85
|
current_step: requirement-analysis
|
|
@@ -87,7 +87,7 @@ assigned_to: {当前 AI 代理}
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
注意:`created_by` 为 `human`,因为任务来源于用户的描述。
|
|
90
|
-
|
|
90
|
+
priority / effort 必填:由 AI 从任务标题与描述推断后填入(候选值见 `.agents/rules/issue-fields.md`;中文输入按本地化映射规范化)。start_date / target_date 创建时保持留空,由 analyze-task / plan-task 阶段填入;不要臆测日期。
|
|
91
91
|
|
|
92
92
|
### 3. 更新任务状态
|
|
93
93
|
|
|
@@ -91,6 +91,7 @@ Update `.agents/workspace/active/{task-id}/task.md`:
|
|
|
91
91
|
- `assigned_to`: {current AI agent}
|
|
92
92
|
- `updated_at`: {current time}
|
|
93
93
|
- `agent_infra_version`: value from `.agents/rules/version-stamp.md`
|
|
94
|
+
- If `target_date` is empty, write an estimated completion date based on the effort estimate (`YYYY-MM-DD`); leave it empty without blocking when no reasonable estimate exists; keep any existing value
|
|
94
95
|
- Record the plan artifact for this round: `{plan-artifact}` (Round `{plan-round}`)
|
|
95
96
|
- If the task template contains a `## Design` section, update it to link to `{plan-artifact}`
|
|
96
97
|
- Mark technical-design as complete in workflow progress and include the actual round when the task template supports it
|
|
@@ -104,6 +105,7 @@ If task.md contains a valid `issue_number`, perform these sync actions (skip and
|
|
|
104
105
|
- Set `status: pending-design-work` by following issue-sync.md
|
|
105
106
|
- Create or update the task comment marker defined in `.agents/rules/issue-sync.md` (follow the task.md comment sync rule in issue-sync.md)
|
|
106
107
|
- Publish the `{plan-artifact}` comment
|
|
108
|
+
- Read `.agents/rules/issue-fields.md` and follow Flow A to sync every non-empty Issue field (`priority`/`effort`/`start_date`/`target_date`) from `task.md` to the Issue (idempotent; skip without blocking when `has_push=false` or the fetch/write fails)
|
|
107
109
|
|
|
108
110
|
### 8. Verification Gate
|
|
109
111
|
|
|
@@ -91,6 +91,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
|
|
|
91
91
|
- `assigned_to`:{当前 AI 代理}
|
|
92
92
|
- `updated_at`:{当前时间}
|
|
93
93
|
- `agent_infra_version`:按 `.agents/rules/version-stamp.md` 取值
|
|
94
|
+
- 若 `target_date` 为空,基于工作量评估写入预估完成日(`YYYY-MM-DD`);无法合理预估时保持留空、不阻塞;已有值则保留
|
|
94
95
|
- 记录本轮方案产物:`{plan-artifact}`(Round `{plan-round}`)
|
|
95
96
|
- 如任务模板包含 `## 设计` 段落,更新为指向 `{plan-artifact}` 的链接
|
|
96
97
|
- 在工作流进度中标记 technical-design 为已完成,并注明实际轮次(如果任务模板支持)
|
|
@@ -104,6 +105,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
|
|
|
104
105
|
- 按 issue-sync.md 设置 `status: pending-design-work`
|
|
105
106
|
- 创建或更新 `.agents/rules/issue-sync.md` 中定义的 task 评论标记(按 issue-sync.md 的 task.md 评论同步规则)
|
|
106
107
|
- 发布 `{plan-artifact}` 评论
|
|
108
|
+
- 读取 `.agents/rules/issue-fields.md`,按流程 A 把 `task.md` 中所有非空的 Issue 字段(`priority`/`effort`/`start_date`/`target_date`)同步到 Issue(幂等;`has_push=false` 或取数/写入失败时跳过,不阻断)
|
|
107
109
|
|
|
108
110
|
### 8. 完成校验
|
|
109
111
|
|