@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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/lib/defaults.json +1 -1
  4. package/dist/lib/sandbox/commands/create.js +27 -19
  5. package/dist/lib/sandbox/config.js +24 -6
  6. package/dist/lib/sandbox/credentials.js +43 -24
  7. package/dist/lib/sandbox/runtime-engines.js +27 -0
  8. package/dist/package.json +1 -1
  9. package/lib/defaults.json +1 -1
  10. package/lib/sandbox/commands/create.ts +29 -19
  11. package/lib/sandbox/config.ts +34 -6
  12. package/lib/sandbox/credentials.ts +49 -26
  13. package/lib/sandbox/runtime-engines.ts +39 -0
  14. package/package.json +3 -1
  15. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  16. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  17. package/templates/.agents/rules/release-commands.github.en.md +9 -3
  18. package/templates/.agents/rules/release-commands.github.zh-CN.md +9 -3
  19. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -0
  20. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -0
  21. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
  22. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
  23. package/templates/.agents/skills/create-pr/config/verify.json +1 -0
  24. package/templates/.agents/skills/create-release-note/SKILL.en.md +8 -11
  25. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +8 -11
  26. package/templates/.agents/skills/create-task/SKILL.en.md +3 -3
  27. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -3
  28. package/templates/.agents/skills/plan-task/SKILL.en.md +2 -0
  29. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -0
  30. 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.1",
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.1",
756
+ "templateVersion": "v0.6.2",
757
757
  "templates": {
758
758
  "sources": [
759
759
  { "type": "local", "path": "~/private-templates" }
@@ -5,7 +5,7 @@
5
5
  "sandbox": {
6
6
  "engine": null,
7
7
  "runtimes": [
8
- "node20"
8
+ "node22"
9
9
  ],
10
10
  "tools": [
11
11
  "claude-code",
@@ -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 { assertClaudeCredentialsAvailable, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
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
- // Fail fast before any filesystem/docker side effects so a missing
829
- // Claude Code credential blob doesn't leave the user with a stale
830
- // worktree, docker image, or temporary Dockerfile they need to manually
831
- // clean up.
832
- assertClaudeCredentialsAvailable(effectiveConfig.home, effectiveConfig.project, resolvedTools);
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 resolvedTools) {
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 `${resolvedTools.length} tool config directories ready`;
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(resolvedTools, engine);
988
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
982
989
  let hostShellConfig;
983
990
  try {
984
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
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
- // Credential availability is asserted up-front in create() so we
989
- // know the shared credentials file already exists at this point.
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 = resolvedTools.find(({ tool }) => tool.id === 'codex');
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 = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1004
+ const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
997
1005
  if (geminiEntry) {
998
1006
  ensureGeminiWorkspaceTrust(geminiEntry.dir);
999
1007
  }
1000
- const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
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 = resolvedTools.flatMap(({ tool, dir }) => [
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 = resolvedTools.flatMap(({ tool }) => (tool.hostLiveMounts ?? [])
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 resolvedTools) {
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 = resolvedTools.map(({ tool, dir }) => {
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: ['node20'],
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: Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
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: typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null,
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 assertClaudeCredentialsAvailable(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
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
- if (inspection.status === 'KEYCHAIN_LOCKED') {
409
- throw new Error([
410
- 'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
411
- '',
412
- buildLockedGuidance()
413
- ].join('\n'));
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
- if (!blob) {
421
- throw new Error([
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
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module"
5
5
  }
package/lib/defaults.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "sandbox": {
6
6
  "engine": null,
7
7
  "runtimes": [
8
- "node20"
8
+ "node22"
9
9
  ],
10
10
  "tools": [
11
11
  "claude-code",
@@ -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
- assertClaudeCredentialsAvailable,
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
- // Fail fast before any filesystem/docker side effects so a missing
1104
- // Claude Code credential blob doesn't leave the user with a stale
1105
- // worktree, docker image, or temporary Dockerfile they need to manually
1106
- // clean up.
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 resolvedTools) {
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 `${resolvedTools.length} tool config directories ready`;
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(resolvedTools, engine);
1295
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1287
1296
  let hostShellConfig: HostShellConfig;
1288
1297
  try {
1289
- const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
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
- // Credential availability is asserted up-front in create() so we
1294
- // know the shared credentials file already exists at this point.
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 = resolvedTools.find(({ tool }) => tool.id === 'codex');
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 = resolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1311
+ const geminiEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'gemini-cli');
1302
1312
  if (geminiEntry) {
1303
1313
  ensureGeminiWorkspaceTrust(geminiEntry.dir);
1304
1314
  }
1305
- const opencodeEntry = resolvedTools.find(({ tool }) => tool.id === 'opencode');
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 = resolvedTools.flatMap(({ tool, dir }) => [
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 = resolvedTools.flatMap(({ tool }) =>
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 resolvedTools) {
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 = resolvedTools.map(({ tool, dir }) => {
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.'
@@ -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: ['node20'],
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({ platformFn = platform }: { platformFn?: PlatformFn } = {}): SandboxConfig {
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: Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
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: typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null,
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 assertClaudeCredentialsAvailable(
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
- ): void {
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
- if (inspection.status === 'KEYCHAIN_LOCKED') {
606
- throw new Error([
607
- 'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
608
- '',
609
- buildLockedGuidance()
610
- ].join('\n'));
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
- if (!blob) {
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.1",
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
- ### 6.5 Set Issue Fields (Optional)
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
- ### 7. Write Back task.md
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
- ### 8. Return the Result
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
- ### 6.5 设置 Issue 字段(可选)
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
- ### 7. 回写 task.md
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
- ### 8. 返回结果
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 creating a draft release.
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
- ## Create a Draft Release
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 create "v{version}" --draft --title "v{version}" --notes-file "{notes-file}"
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,或创建 draft release 前先读取本文件。
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
- ## 创建 Draft Release
40
+ ## 发布 Release notes
41
+
42
+ `v{version}` 的 GitHub Release 由 release 工作流自动创建并发布,为 Homebrew bottle 提供稳定的上传落点。本命令把精修后的 notes 写到这个已存在的 Release 上;若 Release 尚不存在则兜底创建。
41
43
 
42
44
  ```bash
43
- gh release create "v{version}" --draft --title "v{version}" --notes-file "{notes-file}"
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
 
@@ -24,6 +24,7 @@
24
24
  "verify_in_labels_match_pr": true,
25
25
  "verify_pr_type_label": true,
26
26
  "verify_pr_assignee": true,
27
+ "verify_issue_fields": true,
27
28
  "verify_milestone": true,
28
29
  "expected_pr_comment_marker_key": "prSummary"
29
30
  }
@@ -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. Create a draft release?
159
+ 2. Write these notes onto the Release for this version?
160
160
 
161
- ### 9. Create Draft Release (If Confirmed)
161
+ ### 9. Publish the Release Notes (If Confirmed)
162
162
 
163
- Create the draft release by following `.agents/rules/release-commands.md`.
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
- Draft Release created.
167
+ Release notes updated.
168
168
 
169
- - URL: {draft-release-url}
169
+ - URL: {release-url}
170
170
  - Version: v{version}
171
- - Status: Draft
171
+ - Status: Published
172
172
 
173
- Please review and publish on the platform:
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. **Draft mode**: Creates a draft - won't auto-publish
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. 是否创建 draft release
159
+ 2. 是否把 notes 写入该版本的 Release
160
160
 
161
- ### 9. 创建 Draft Release(如确认)
161
+ ### 9. 发布 Release notes(如确认)
162
162
 
163
- 按 `.agents/rules/release-commands.md` Draft Release 创建命令执行。
163
+ 按 `.agents/rules/release-commands.md` 的「发布 Release notes」命令执行(写入已由 release 工作流自动创建/发布的 Release;不存在时兜底创建)。
164
164
 
165
165
  输出:
166
166
  ```
167
- Draft Release created.
167
+ Release notes 已更新。
168
168
 
169
- - URL: {draft-release-url}
169
+ - URL: {release-url}
170
170
  - Version: v{version}
171
- - Status: Draft
171
+ - Status: Published
172
172
 
173
- Please review and publish on the platform:
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: # optional; Urgent | High | Medium | Low
82
- effort: # optional; High | Medium | Low
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
- Optional Issue field metadata may be left empty at task creation; do not invent dates.
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: # 可选;Urgent | High | Medium | Low
82
- effort: # 可选;High | Medium | Low
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
- 可选 Issue 字段元数据在创建任务时可留空;不要臆测日期。
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
 
@@ -26,7 +26,7 @@ const DEFAULTS = {
26
26
  "sandbox": {
27
27
  "engine": null,
28
28
  "runtimes": [
29
- "node20"
29
+ "node22"
30
30
  ],
31
31
  "tools": [
32
32
  "claude-code",