@fitlab-ai/agent-infra 0.6.3 → 0.6.4
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/sandbox/clipboard/bridge.js +7 -5
- package/dist/lib/sandbox/clipboard/darwin.js +7 -14
- package/dist/lib/sandbox/commands/create.js +4 -0
- package/dist/lib/sandbox/commands/enter.js +6 -1
- package/dist/lib/sandbox/host-timezone.js +33 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/lib/sandbox/clipboard/bridge.ts +7 -6
- package/lib/sandbox/clipboard/darwin.ts +15 -14
- package/lib/sandbox/commands/create.ts +4 -0
- package/lib/sandbox/commands/enter.ts +7 -1
- package/lib/sandbox/host-timezone.ts +42 -0
- package/lib/sandbox/runtimes/base.dockerfile +21 -16
- package/package.json +7 -7
- package/templates/.agents/rules/create-issue.github.en.md +19 -1
- package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
- package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
- package/templates/.agents/rules/testing-discipline.en.md +44 -0
- package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
package/README.md
CHANGED
|
@@ -787,7 +787,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
|
|
|
787
787
|
"project": "my-project",
|
|
788
788
|
"org": "my-org",
|
|
789
789
|
"language": "en",
|
|
790
|
-
"templateVersion": "v0.6.
|
|
790
|
+
"templateVersion": "v0.6.4",
|
|
791
791
|
"templates": {
|
|
792
792
|
"sources": [
|
|
793
793
|
{ "type": "local", "path": "~/private-templates" }
|
package/README.zh-CN.md
CHANGED
|
@@ -760,7 +760,7 @@ import-issue #42 从 GitHub Issue 导入任务
|
|
|
760
760
|
"project": "my-project",
|
|
761
761
|
"org": "my-org",
|
|
762
762
|
"language": "en",
|
|
763
|
-
"templateVersion": "v0.6.
|
|
763
|
+
"templateVersion": "v0.6.4",
|
|
764
764
|
"templates": {
|
|
765
765
|
"sources": [
|
|
766
766
|
{ "type": "local", "path": "~/private-templates" }
|
|
@@ -90,13 +90,15 @@ async function runBridge({ child, home, adapter, writeStderr, stdin, stdout, det
|
|
|
90
90
|
const onSigterm = () => child.kill('SIGTERM');
|
|
91
91
|
function handleCtrlV(match, target) {
|
|
92
92
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
// readImagePng returns null both for "no image on clipboard" and for
|
|
94
|
+
// unexpected read failures; both cases forward the original Ctrl+V so
|
|
95
|
+
// the container app handles it as a regular keystroke. The throw branch
|
|
96
|
+
// below only fires on truly unexpected exceptions (e.g. fs write
|
|
97
|
+
// errors writing to the host clipboard dir).
|
|
97
98
|
const png = adapter.readImagePng();
|
|
98
99
|
if (!png) {
|
|
99
|
-
|
|
100
|
+
target.write(match.raw);
|
|
101
|
+
return;
|
|
100
102
|
}
|
|
101
103
|
const filename = pngClipboardFilename(png);
|
|
102
104
|
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
@@ -2,32 +2,25 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
|
|
5
|
+
// Quick "is osascript callable at all" probe used by available(). Not for
|
|
6
|
+
// clipboard work — clipboard work shares READ_IMAGE_TIMEOUT_MS below.
|
|
7
|
+
// Generous 2s budget: this is a once-per-session bridge enablement check;
|
|
8
|
+
// failing it just disables the clipboard bridge for that session, so we'd
|
|
9
|
+
// rather tolerate a cold osascript spawn than misreport "unavailable".
|
|
10
|
+
const OSASCRIPT_PROBE_TIMEOUT_MS = 2_000;
|
|
6
11
|
const READ_IMAGE_TIMEOUT_MS = 5_000;
|
|
7
12
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
13
|
export function createDarwinClipboardAdapter({ execFn = execFileSync, mkdtempFn = fs.mkdtempSync, readFileFn = fs.readFileSync, rmFn = fs.rmSync } = {}) {
|
|
9
14
|
return {
|
|
10
15
|
available() {
|
|
11
16
|
try {
|
|
12
|
-
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout:
|
|
17
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
|
|
13
18
|
return { ok: true };
|
|
14
19
|
}
|
|
15
20
|
catch {
|
|
16
21
|
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
17
22
|
}
|
|
18
23
|
},
|
|
19
|
-
hasImage() {
|
|
20
|
-
try {
|
|
21
|
-
const output = String(execFn('osascript', ['-e', 'clipboard info'], {
|
|
22
|
-
encoding: 'utf8',
|
|
23
|
-
timeout: HAS_IMAGE_TIMEOUT_MS
|
|
24
|
-
}));
|
|
25
|
-
return /\b(PNGf|TIFF|JPEG|GIFf)\b/.test(output);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
24
|
readImagePng() {
|
|
32
25
|
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
33
26
|
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
@@ -20,6 +20,7 @@ import { validateSelinuxDisableEnv } from "../engines/selinux.js";
|
|
|
20
20
|
import { resolveBuildUid } from "../engines/native.js";
|
|
21
21
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
22
22
|
import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
|
|
23
|
+
import { detectHostTimezone } from "../host-timezone.js";
|
|
23
24
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
24
25
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
25
26
|
const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
|
|
@@ -1046,6 +1047,8 @@ export async function create(args) {
|
|
|
1046
1047
|
const dotfilesMount = dotfilesSnapshot
|
|
1047
1048
|
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1048
1049
|
: [];
|
|
1050
|
+
const hostTz = detectHostTimezone();
|
|
1051
|
+
const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
|
|
1049
1052
|
runEngineTaskCommand(engine, 'docker', [
|
|
1050
1053
|
'run',
|
|
1051
1054
|
'-d',
|
|
@@ -1075,6 +1078,7 @@ export async function create(args) {
|
|
|
1075
1078
|
...liveMountVolumes,
|
|
1076
1079
|
...shellConfigVolumes,
|
|
1077
1080
|
...envFile.dockerArgs,
|
|
1081
|
+
...tzFlags,
|
|
1078
1082
|
'-w',
|
|
1079
1083
|
'/workspace',
|
|
1080
1084
|
effectiveConfig.imageName
|
|
@@ -6,6 +6,7 @@ import { runInteractiveEngine, runSafeEngine } from "../shell.js";
|
|
|
6
6
|
import { resolveTaskBranch } from "../task-resolver.js";
|
|
7
7
|
import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
|
|
8
8
|
import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
|
|
9
|
+
import { detectHostTimezone } from "../host-timezone.js";
|
|
9
10
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
10
11
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
11
12
|
// Terminal-detection variables that interactive TUIs (e.g. claude-code)
|
|
@@ -29,6 +30,10 @@ export function terminalEnvFlags(env = process.env) {
|
|
|
29
30
|
}
|
|
30
31
|
return flags;
|
|
31
32
|
}
|
|
33
|
+
export function hostTimezoneEnvFlags(detect = detectHostTimezone) {
|
|
34
|
+
const tz = detect();
|
|
35
|
+
return tz ? ['-e', `TZ=${tz}`] : [];
|
|
36
|
+
}
|
|
32
37
|
export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
|
|
33
38
|
if (result.status === 'STALE_ACCESS') {
|
|
34
39
|
return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
|
|
@@ -84,7 +89,7 @@ export async function enter(args) {
|
|
|
84
89
|
process.stderr.write(`Warning: Failed to sync Claude Code credentials: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
|
-
const envFlags = terminalEnvFlags();
|
|
92
|
+
const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
|
|
88
93
|
if (cmd.length === 0) {
|
|
89
94
|
try {
|
|
90
95
|
materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
const ZONEINFO_MARK = '/zoneinfo/';
|
|
4
|
+
const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
5
|
+
function safeTimezone(value) {
|
|
6
|
+
if (!value || !IANA_ZONE_RE.test(value)) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function detectHostTimezone(options = {}) {
|
|
12
|
+
const platform = options.platform ?? os.platform();
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
if (env.TZ) {
|
|
15
|
+
return safeTimezone(env.TZ);
|
|
16
|
+
}
|
|
17
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const readlink = options.readlink ?? fs.readlinkSync;
|
|
21
|
+
try {
|
|
22
|
+
const target = readlink('/etc/localtime');
|
|
23
|
+
const idx = target.indexOf(ZONEINFO_MARK);
|
|
24
|
+
if (idx < 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=host-timezone.js.map
|
|
@@ -3,7 +3,6 @@ FROM ubuntu:22.04
|
|
|
3
3
|
LABEL description="AI coding sandbox"
|
|
4
4
|
|
|
5
5
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
6
|
-
ENV TZ=Asia/Shanghai
|
|
7
6
|
|
|
8
7
|
ARG HOST_UID=1000
|
|
9
8
|
ARG HOST_GID=1000
|
|
@@ -22,7 +21,7 @@ RUN apt-get update && apt-get install -y \
|
|
|
22
21
|
build-essential ca-certificates gnupg lsb-release \
|
|
23
22
|
libevent-core-2.1-7 libncursesw6 libtinfo6 \
|
|
24
23
|
pkg-config bison libevent-dev libncurses-dev \
|
|
25
|
-
locales \
|
|
24
|
+
locales tzdata \
|
|
26
25
|
&& locale-gen en_US.UTF-8 \
|
|
27
26
|
&& (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
28
27
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
|
|
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
45
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
46
45
|
|
|
47
46
|
# Enable extended keys in CSI u format so Shift+Enter and other modified
|
|
48
|
-
# keys are forwarded through tmux. Preserve terminal
|
|
47
|
+
# keys are forwarded through tmux. Preserve terminal/timezone variables
|
|
49
48
|
# injected at `docker exec` time when new tmux sessions are created.
|
|
50
49
|
RUN printf '%s\n' \
|
|
51
50
|
'set -g extended-keys always' \
|
|
52
51
|
'set -g extended-keys-format csi-u' \
|
|
53
52
|
"set -as terminal-features 'xterm*:extkeys'" \
|
|
54
|
-
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
|
|
53
|
+
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
|
|
55
54
|
'set -g mouse on' \
|
|
56
55
|
'set -g status-interval 1' \
|
|
57
56
|
'set -g status-right-length 80' \
|
|
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
|
|
|
146
145
|
#!/bin/sh
|
|
147
146
|
set -eu
|
|
148
147
|
|
|
149
|
-
sandbox-dotfiles-link >/dev/null || true
|
|
148
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
150
149
|
|
|
151
150
|
SESSION=work
|
|
152
151
|
|
|
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|
|
154
153
|
exec bash
|
|
155
154
|
fi
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
|
|
162
|
-
while read -r name attached; do
|
|
163
|
-
[ "$name" = "$SESSION" ] && continue
|
|
156
|
+
# Drop stale grouped sessions left by older entry-script versions (the windows
|
|
157
|
+
# live on $SESSION, so killing the group members only removes view entries).
|
|
158
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
|
|
164
159
|
case "$name" in
|
|
165
|
-
|
|
160
|
+
"$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
|
|
166
161
|
esac
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
# Reuse the single $SESSION; -d detaches any pre-existing client so the new
|
|
165
|
+
# one becomes the sole owner of window-size, eliminating size races.
|
|
166
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
167
|
+
# Push the per-exec TZ into the running session's env so new
|
|
168
|
+
# windows/panes pick up the host timezone without a session kill.
|
|
169
|
+
if [ -n "${TZ:-}" ]; then
|
|
170
|
+
tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
|
|
171
|
+
fi
|
|
172
|
+
exec tmux attach -d -t "$SESSION"
|
|
173
|
+
fi
|
|
169
174
|
|
|
170
|
-
exec tmux new-session -
|
|
175
|
+
exec tmux new-session -s "$SESSION"
|
|
171
176
|
SCRIPT
|
|
172
177
|
|
|
173
178
|
ENV LANG=en_US.UTF-8
|
|
@@ -153,14 +153,15 @@ async function runBridge({
|
|
|
153
153
|
|
|
154
154
|
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
|
|
155
155
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
// readImagePng returns null both for "no image on clipboard" and for
|
|
157
|
+
// unexpected read failures; both cases forward the original Ctrl+V so
|
|
158
|
+
// the container app handles it as a regular keystroke. The throw branch
|
|
159
|
+
// below only fires on truly unexpected exceptions (e.g. fs write
|
|
160
|
+
// errors writing to the host clipboard dir).
|
|
161
161
|
const png = adapter.readImagePng();
|
|
162
162
|
if (!png) {
|
|
163
|
-
|
|
163
|
+
target.write(match.raw);
|
|
164
|
+
return;
|
|
164
165
|
}
|
|
165
166
|
const filename = pngClipboardFilename(png);
|
|
166
167
|
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
@@ -4,7 +4,12 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Quick "is osascript callable at all" probe used by available(). Not for
|
|
8
|
+
// clipboard work — clipboard work shares READ_IMAGE_TIMEOUT_MS below.
|
|
9
|
+
// Generous 2s budget: this is a once-per-session bridge enablement check;
|
|
10
|
+
// failing it just disables the clipboard bridge for that session, so we'd
|
|
11
|
+
// rather tolerate a cold osascript spawn than misreport "unavailable".
|
|
12
|
+
const OSASCRIPT_PROBE_TIMEOUT_MS = 2_000;
|
|
8
13
|
const READ_IMAGE_TIMEOUT_MS = 5_000;
|
|
9
14
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
10
15
|
|
|
@@ -12,7 +17,14 @@ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Bu
|
|
|
12
17
|
|
|
13
18
|
export type DarwinClipboardAdapter = {
|
|
14
19
|
available(): { ok: true } | { ok: false; reason: string };
|
|
15
|
-
|
|
20
|
+
// Returns PNG bytes when the clipboard holds (or can synthesize) an image,
|
|
21
|
+
// null otherwise. No separate hasImage(): a probing `clipboard info` call
|
|
22
|
+
// forces NSPasteboard to materialize TIFF/BMP/8BPS representations to
|
|
23
|
+
// report their sizes, which can take seconds when the clipboard holds a
|
|
24
|
+
// Retina screenshot. Letting AppleScript's `as «class PNGf»` either succeed
|
|
25
|
+
// (image present, possibly auto-converted from TIFF/JPEG/GIF) or error
|
|
26
|
+
// (nothing PNG-coercible) keeps the path O(PNG size) regardless of how
|
|
27
|
+
// many other representations the source declared.
|
|
16
28
|
readImagePng(): Buffer | null;
|
|
17
29
|
};
|
|
18
30
|
|
|
@@ -30,23 +42,12 @@ export function createDarwinClipboardAdapter({
|
|
|
30
42
|
return {
|
|
31
43
|
available() {
|
|
32
44
|
try {
|
|
33
|
-
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout:
|
|
45
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
|
|
34
46
|
return { ok: true };
|
|
35
47
|
} catch {
|
|
36
48
|
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
37
49
|
}
|
|
38
50
|
},
|
|
39
|
-
hasImage() {
|
|
40
|
-
try {
|
|
41
|
-
const output = String(execFn('osascript', ['-e', 'clipboard info'], {
|
|
42
|
-
encoding: 'utf8',
|
|
43
|
-
timeout: HAS_IMAGE_TIMEOUT_MS
|
|
44
|
-
}));
|
|
45
|
-
return /\b(PNGf|TIFF|JPEG|GIFf)\b/.test(output);
|
|
46
|
-
} catch {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
51
|
readImagePng() {
|
|
51
52
|
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
52
53
|
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
redactCommandError,
|
|
49
49
|
validateClaudeCredentialsEnvOverride
|
|
50
50
|
} from '../credentials.ts';
|
|
51
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
51
52
|
|
|
52
53
|
const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
|
|
53
54
|
const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
|
|
@@ -1364,6 +1365,8 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1364
1365
|
const dotfilesMount = dotfilesSnapshot
|
|
1365
1366
|
? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
|
|
1366
1367
|
: [];
|
|
1368
|
+
const hostTz = detectHostTimezone();
|
|
1369
|
+
const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
|
|
1367
1370
|
|
|
1368
1371
|
runEngineTaskCommand(engine, 'docker', [
|
|
1369
1372
|
'run',
|
|
@@ -1398,6 +1401,7 @@ export async function create(args: string[]): Promise<void> {
|
|
|
1398
1401
|
...liveMountVolumes,
|
|
1399
1402
|
...shellConfigVolumes,
|
|
1400
1403
|
...envFile.dockerArgs,
|
|
1404
|
+
...tzFlags,
|
|
1401
1405
|
'-w',
|
|
1402
1406
|
'/workspace',
|
|
1403
1407
|
effectiveConfig.imageName
|
|
@@ -12,6 +12,7 @@ import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
|
|
|
12
12
|
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
13
13
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
14
14
|
import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
|
|
15
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
15
16
|
|
|
16
17
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
17
18
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
@@ -39,6 +40,11 @@ export function terminalEnvFlags(env: NodeJS.ProcessEnv = process.env): string[]
|
|
|
39
40
|
return flags;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export function hostTimezoneEnvFlags(detect = detectHostTimezone): string[] {
|
|
44
|
+
const tz = detect();
|
|
45
|
+
return tz ? ['-e', `TZ=${tz}`] : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
export function formatCredentialSyncStatus(
|
|
43
49
|
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
44
50
|
isTTY = process.stderr.isTTY
|
|
@@ -101,7 +107,7 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
const envFlags = terminalEnvFlags();
|
|
110
|
+
const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
|
|
105
111
|
if (cmd.length === 0) {
|
|
106
112
|
try {
|
|
107
113
|
materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export type DetectHostTimezoneOptions = {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
readlink?: (targetPath: string) => string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ZONEINFO_MARK = '/zoneinfo/';
|
|
11
|
+
const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
12
|
+
|
|
13
|
+
function safeTimezone(value: string | undefined): string | null {
|
|
14
|
+
if (!value || !IANA_ZONE_RE.test(value)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectHostTimezone(options: DetectHostTimezoneOptions = {}): string | null {
|
|
21
|
+
const platform = options.platform ?? os.platform();
|
|
22
|
+
const env = options.env ?? process.env;
|
|
23
|
+
if (env.TZ) {
|
|
24
|
+
return safeTimezone(env.TZ);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const readlink = options.readlink ?? fs.readlinkSync;
|
|
32
|
+
try {
|
|
33
|
+
const target = readlink('/etc/localtime');
|
|
34
|
+
const idx = target.indexOf(ZONEINFO_MARK);
|
|
35
|
+
if (idx < 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -3,7 +3,6 @@ FROM ubuntu:22.04
|
|
|
3
3
|
LABEL description="AI coding sandbox"
|
|
4
4
|
|
|
5
5
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
6
|
-
ENV TZ=Asia/Shanghai
|
|
7
6
|
|
|
8
7
|
ARG HOST_UID=1000
|
|
9
8
|
ARG HOST_GID=1000
|
|
@@ -22,7 +21,7 @@ RUN apt-get update && apt-get install -y \
|
|
|
22
21
|
build-essential ca-certificates gnupg lsb-release \
|
|
23
22
|
libevent-core-2.1-7 libncursesw6 libtinfo6 \
|
|
24
23
|
pkg-config bison libevent-dev libncurses-dev \
|
|
25
|
-
locales \
|
|
24
|
+
locales tzdata \
|
|
26
25
|
&& locale-gen en_US.UTF-8 \
|
|
27
26
|
&& (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
28
27
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
|
|
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
45
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
46
45
|
|
|
47
46
|
# Enable extended keys in CSI u format so Shift+Enter and other modified
|
|
48
|
-
# keys are forwarded through tmux. Preserve terminal
|
|
47
|
+
# keys are forwarded through tmux. Preserve terminal/timezone variables
|
|
49
48
|
# injected at `docker exec` time when new tmux sessions are created.
|
|
50
49
|
RUN printf '%s\n' \
|
|
51
50
|
'set -g extended-keys always' \
|
|
52
51
|
'set -g extended-keys-format csi-u' \
|
|
53
52
|
"set -as terminal-features 'xterm*:extkeys'" \
|
|
54
|
-
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
|
|
53
|
+
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
|
|
55
54
|
'set -g mouse on' \
|
|
56
55
|
'set -g status-interval 1' \
|
|
57
56
|
'set -g status-right-length 80' \
|
|
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
|
|
|
146
145
|
#!/bin/sh
|
|
147
146
|
set -eu
|
|
148
147
|
|
|
149
|
-
sandbox-dotfiles-link >/dev/null || true
|
|
148
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
150
149
|
|
|
151
150
|
SESSION=work
|
|
152
151
|
|
|
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|
|
154
153
|
exec bash
|
|
155
154
|
fi
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
|
|
162
|
-
while read -r name attached; do
|
|
163
|
-
[ "$name" = "$SESSION" ] && continue
|
|
156
|
+
# Drop stale grouped sessions left by older entry-script versions (the windows
|
|
157
|
+
# live on $SESSION, so killing the group members only removes view entries).
|
|
158
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
|
|
164
159
|
case "$name" in
|
|
165
|
-
|
|
160
|
+
"$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
|
|
166
161
|
esac
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
# Reuse the single $SESSION; -d detaches any pre-existing client so the new
|
|
165
|
+
# one becomes the sole owner of window-size, eliminating size races.
|
|
166
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
167
|
+
# Push the per-exec TZ into the running session's env so new
|
|
168
|
+
# windows/panes pick up the host timezone without a session kill.
|
|
169
|
+
if [ -n "${TZ:-}" ]; then
|
|
170
|
+
tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
|
|
171
|
+
fi
|
|
172
|
+
exec tmux attach -d -t "$SESSION"
|
|
173
|
+
fi
|
|
169
174
|
|
|
170
|
-
exec tmux new-session -
|
|
175
|
+
exec tmux new-session -s "$SESSION"
|
|
171
176
|
SCRIPT
|
|
172
177
|
|
|
173
178
|
ENV LANG=en_US.UTF-8
|
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.4",
|
|
4
4
|
"description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"installer"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@clack/prompts": "1.
|
|
46
|
+
"@clack/prompts": "1.5.1",
|
|
47
47
|
"cross-spawn": "^7.0.6",
|
|
48
48
|
"picocolors": "1.1.1",
|
|
49
49
|
"semver": "^7.8.1",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"demo:regen": "sh scripts/demo-regen.sh",
|
|
55
55
|
"prepare": "git config core.hooksPath .git-hooks || true",
|
|
56
56
|
"typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.jschecks.json --noEmit",
|
|
57
|
-
"test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test tests/
|
|
58
|
-
"test:core": "npm run build && node --experimental-strip-types --no-warnings --test tests/
|
|
59
|
-
"test": "npm run build && node --experimental-strip-types --no-warnings --test tests
|
|
60
|
-
"test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage --test-coverage-exclude=
|
|
61
|
-
"prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test tests
|
|
57
|
+
"test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\"",
|
|
58
|
+
"test:core": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\"",
|
|
59
|
+
"test": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\"",
|
|
60
|
+
"test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage \"--test-coverage-exclude=tests/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov \"tests/**/*.test.ts\"",
|
|
61
|
+
"prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\""
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -115,7 +115,25 @@ When applying the Issue Type, follow the "Set Issue Type" command in `.agents/ru
|
|
|
115
115
|
|
|
116
116
|
#### milestone
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
**Mandatory; do not skip.** This section expands `.agents/rules/milestone-inference.md` Phase 1 in place and keeps the same semantics; do not treat it as optional inference.
|
|
119
|
+
|
|
120
|
+
Select the milestone using these numbered steps, with priority strictly aligned to Phase 1:
|
|
121
|
+
|
|
122
|
+
1. If `has_triage=false`: omit `--milestone` immediately and skip this section.
|
|
123
|
+
2. List all open milestones in the repository:
|
|
124
|
+
```bash
|
|
125
|
+
gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
126
|
+
--jq '.[].title'
|
|
127
|
+
```
|
|
128
|
+
3. If task.md frontmatter explicitly provides a `milestone` field and that value appears in the step 2 list: use that value directly as `{milestone-arg}` and skip steps 4 / 5.
|
|
129
|
+
4. Filter the step 2 result with `^[0-9]+\.[0-9]+\.x$`.
|
|
130
|
+
- Non-empty: sort by major and minor numerically, then choose the smallest release line as `{milestone-arg}`.
|
|
131
|
+
5. If step 4 has no candidates: fall back to `General Backlog`.
|
|
132
|
+
- `General Backlog` exists in the step 2 result: use that milestone.
|
|
133
|
+
- `General Backlog` does not exist: omit `--milestone` only in this case.
|
|
134
|
+
6. If the step 2 `gh api` call fails (network / authentication error): handle it as "no candidates" and continue to step 5.
|
|
135
|
+
|
|
136
|
+
When a milestone is selected, pass the release line (or `General Backlog` or the explicit task.md value) as `{milestone-arg}` to the `gh issue create` command in step 5; keep the expansion rule at the end of §5 unchanged.
|
|
119
137
|
|
|
120
138
|
### 5. Call the GitHub CLI to Create the Issue
|
|
121
139
|
|
|
@@ -115,7 +115,25 @@ Issue 模板检测:按 `.agents/rules/issue-pr-commands.md` 的 "Issue 模板
|
|
|
115
115
|
|
|
116
116
|
#### milestone
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
**必须执行,不得跳过**。本节是 `.agents/rules/milestone-inference.md` 阶段 1 的就地展开,语义与之对齐;不要把它当成"可选推断"。
|
|
119
|
+
|
|
120
|
+
按以下编号步骤选取 milestone(优先级与阶段 1 严格一致):
|
|
121
|
+
|
|
122
|
+
1. 如果 `has_triage=false`:直接省略 `--milestone`,跳过本节。
|
|
123
|
+
2. 列出仓库 open 状态的全部 milestone:
|
|
124
|
+
```bash
|
|
125
|
+
gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
126
|
+
--jq '.[].title'
|
|
127
|
+
```
|
|
128
|
+
3. 如果 `task.md` frontmatter 显式给出 `milestone` 字段,且该值出现在步骤 2 列表中:直接使用该值作为 `{milestone-arg}`,跳过步骤 4 / 5。
|
|
129
|
+
4. 用正则 `^[0-9]+\.[0-9]+\.x$` 过滤步骤 2 结果。
|
|
130
|
+
- 非空:按 major、minor 数值升序排序,取最小的版本线作为 `{milestone-arg}`。
|
|
131
|
+
5. 步骤 4 候选为空:尝试回退到 `General Backlog`。
|
|
132
|
+
- 步骤 2 结果中存在 `General Backlog`:使用该 milestone。
|
|
133
|
+
- 不存在 `General Backlog`:仅在此情况下省略 `--milestone`。
|
|
134
|
+
6. 步骤 2 的 `gh api` 调用失败(网络 / 认证错误):按"无候选"处理,落到步骤 5。
|
|
135
|
+
|
|
136
|
+
设置成功时把版本线(或 `General Backlog` 或 task.md 显式值)作为 `{milestone-arg}` 带入步骤 5 的 `gh issue create` 命令;§5 末尾的展开规则保持不变。
|
|
119
137
|
|
|
120
138
|
### 5. 调用 GitHub CLI 创建 Issue
|
|
121
139
|
|
|
@@ -44,6 +44,18 @@ Only match titles in `X.Y.x` format and choose the smallest major/minor pair num
|
|
|
44
44
|
|
|
45
45
|
Direct milestone writes are triage-gated. When the caller detected `has_triage=false`, omit `--milestone` and continue.
|
|
46
46
|
|
|
47
|
+
### Backfill when called from `import-issue`
|
|
48
|
+
|
|
49
|
+
When `import-issue` imports an existing Issue whose current milestone is empty, infer a release line using the priority above, including the `General Backlog` fallback. When a non-empty release line is found, write it back to the remote Issue:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
if [ "$has_triage" = "true" ]; then
|
|
53
|
+
gh issue edit {issue-number} -R "$upstream_repo" --milestone "{version}" 2>/dev/null || true
|
|
54
|
+
fi
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If `has_triage=false`, inference returns empty, or `gh issue edit` fails, skip and continue without blocking the `import-issue` workflow.
|
|
58
|
+
|
|
47
59
|
## Phase 2: `implement-task`
|
|
48
60
|
|
|
49
61
|
Goal: narrow the Issue milestone from a release line to a concrete version when implementation starts.
|
|
@@ -44,6 +44,18 @@ gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
|
44
44
|
|
|
45
45
|
Milestone 设置属于 `has_triage` 权限范围;如果调用方检测到 `has_triage=false`,则省略 `--milestone` 并继续。
|
|
46
46
|
|
|
47
|
+
### `import-issue` 调用时的兜底
|
|
48
|
+
|
|
49
|
+
`import-issue` 导入既有 Issue 时,若 Issue 当前 milestone 为空,按上述优先级推断版本线(含 `General Backlog` 回退)。命中非空版本线后,回写到远端 Issue:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
if [ "$has_triage" = "true" ]; then
|
|
53
|
+
gh issue edit {issue-number} -R "$upstream_repo" --milestone "{version}" 2>/dev/null || true
|
|
54
|
+
fi
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
如果 `has_triage=false`、推断结果为空、或 `gh issue edit` 失败,跳过并继续,不阻断 `import-issue` 工作流。
|
|
58
|
+
|
|
47
59
|
## 阶段 2:`implement-task`
|
|
48
60
|
|
|
49
61
|
目标:开始开发时,把 Issue milestone 从版本线收窄到具体版本。
|
|
@@ -38,3 +38,47 @@ This mirrors "Goal-Driven Execution" in AGENTS.md: define a verifiable success c
|
|
|
38
38
|
- **Over-mocking**: Stub only real boundaries such as network, filesystem, time, or randomness; do not mock the logic of the unit under test, or the test only proves that the mock followed the script.
|
|
39
39
|
- **Testing implementation details**: Prefer assertions on public APIs, artifacts, state changes, or error results; avoid assertions on private functions, internal call order, or temporary data structures.
|
|
40
40
|
- **Insufficient assertions**: Assertions must pin down concrete expected values; do not replace checks on key fields, counts, and boundaries with "does not throw" or "result exists".
|
|
41
|
+
|
|
42
|
+
## Coverage positioning (informational layer)
|
|
43
|
+
|
|
44
|
+
> CI emits coverage via `node --test --experimental-test-coverage` as a hint about "which files are weakly tested". It is **not a merge gate**.
|
|
45
|
+
|
|
46
|
+
### Local run
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run test:coverage
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tail of stdout prints per-file line / branch / function coverage and the uncovered line numbers.
|
|
53
|
+
|
|
54
|
+
### CI display
|
|
55
|
+
|
|
56
|
+
`.github/workflows/unit-tests.yml` writes the coverage block into the GitHub Actions step summary on the ubuntu-latest shard (visible on the PR Checks page). Windows / macOS shards do not duplicate this output.
|
|
57
|
+
|
|
58
|
+
The Codecov badge at the top of the README is generated by Codecov after `.github/workflows/unit-tests.yml` uploads `coverage.lcov` from the ubuntu-latest shard.
|
|
59
|
+
|
|
60
|
+
### Boundaries
|
|
61
|
+
|
|
62
|
+
- **No percentage thresholds**: Threshold flags such as `--test-coverage-lines/branches/functions` are forbidden. Goodhart's law reminds us that once coverage becomes a metric, developers write "coverage-friendly but behavior-weak" tests.
|
|
63
|
+
- **Third-party services for the badge only**: Codecov hosts the README coverage badge, but the root `codecov.yml` explicitly disables its project/patch status checks and PR comments — Codecov only displays a number here and does not participate in merge decisions. Do not integrate other services such as coveralls.
|
|
64
|
+
- **No tier split**: Coverage is currently emitted only for the full `test` tier; smoke / core tier coverage has no independent value.
|
|
65
|
+
- **Does not block PRs**: The CI step uses `continue-on-error: true`, so a failed coverage collection does not affect merges.
|
|
66
|
+
|
|
67
|
+
### Where New Tests Go
|
|
68
|
+
|
|
69
|
+
The test directory determines which npm script runs a test file:
|
|
70
|
+
|
|
71
|
+
- `tests/unit/<module>/`: fast structural or pure-function tests; does not spawn the real CLI process or depend on external tools, suitable for `test:smoke`.
|
|
72
|
+
- `tests/integration/<module>/`: combines multiple modules, runs CLI subprocesses, touches temporary filesystems, or verifies template sync flows while staying stable and reasonably fast, suitable for `test:core`.
|
|
73
|
+
- `tests/e2e/<module>/`: slower contract, platform-sync, packaged-output, cross-process, or end-to-end workflow tests, run only by full `npm test`.
|
|
74
|
+
|
|
75
|
+
Modules remain as second-level directories such as `cli`, `core`, `scripts`, and `templates`. Shared helpers and fixtures stay in `tests/helpers/`, `tests/helpers.ts`, and `tests/fixtures/`; do not place them under a tier.
|
|
76
|
+
|
|
77
|
+
### Relation to "test tier coverage"
|
|
78
|
+
|
|
79
|
+
Distinguish two concepts:
|
|
80
|
+
|
|
81
|
+
- **Test tier coverage** (`tests/unit/core/test-tier-coverage.test.ts` checks test directory placement and npm script tier mapping): governs "which test files belong to which tier" and is orthogonal to source-line coverage.
|
|
82
|
+
- **Source-line coverage** (this section): governs "which lines of production source are exercised by tests".
|
|
83
|
+
|
|
84
|
+
The two serve different purposes and should not substitute for each other.
|
|
@@ -38,3 +38,47 @@ assert.match(content, /^name: implement-task$/m); // 正向断言已足够
|
|
|
38
38
|
- **mock 过度**:只在网络、文件系统、时间、随机数等真实边界打桩;不要 mock 被测对象自身逻辑,否则测试只验证 mock 是否按预设运行。
|
|
39
39
|
- **测试实现细节**:优先断言公开接口、产物、状态变化或错误结果;避免断言私有函数、内部调用顺序、临时数据结构。
|
|
40
40
|
- **断言不充分**:断言必须锁定具体期望值;不要用"只要不抛异常""结果存在即可"替代对关键字段、数量和边界的验证。
|
|
41
|
+
|
|
42
|
+
## 覆盖率定位(信息层)
|
|
43
|
+
|
|
44
|
+
> CI 中通过 `node --test --experimental-test-coverage` 输出覆盖率,仅作为"哪些文件被测试薄弱"的提示,**不作为 merge gate**。
|
|
45
|
+
|
|
46
|
+
### 本地运行
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run test:coverage
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
stdout 末尾会打印按文件粒度的行 / 分支 / 函数覆盖率以及未覆盖行号。
|
|
53
|
+
|
|
54
|
+
### CI 展示
|
|
55
|
+
|
|
56
|
+
`.github/workflows/unit-tests.yml` 在 ubuntu-latest 分片上把覆盖率块写入 GitHub Actions 的 step summary(PR Checks 页可见)。Windows / macOS 分片不重复输出。
|
|
57
|
+
|
|
58
|
+
README 顶部的 Codecov 徽章由 `.github/workflows/unit-tests.yml` 在 ubuntu-latest 分片上传 `coverage.lcov` 后由 Codecov 生成。
|
|
59
|
+
|
|
60
|
+
### 边界
|
|
61
|
+
|
|
62
|
+
- **不设置百分比阈值**:`--test-coverage-lines/branches/functions` 等阈值参数禁止加入;Goodhart's law 提醒我们一旦把覆盖率作为指标,开发者会写"覆盖率友好但行为弱"的测试。
|
|
63
|
+
- **第三方服务仅用于徽章**:已接入 Codecov 托管 README 覆盖率徽章,但通过根 `codecov.yml` 显式关闭其 project/patch status check 与 PR 评论——Codecov 在本项目只展示数字,不参与 merge 决策。不接入 coveralls 等其他服务。
|
|
64
|
+
- **不区分 tier**:当前只对 full `test` tier 输出覆盖率;smoke / core tier 的覆盖率没有独立价值。
|
|
65
|
+
- **不阻塞 PR**:CI 步骤 `continue-on-error: true`,即便覆盖率采集失败也不影响 merge。
|
|
66
|
+
|
|
67
|
+
### 新测试该放哪一层
|
|
68
|
+
|
|
69
|
+
测试文件放入哪一层决定它会被哪些 npm script 自动执行:
|
|
70
|
+
|
|
71
|
+
- `tests/unit/<module>/`:快速、结构性或纯函数类测试;不启动真实 CLI 子进程,不依赖外部工具,适合 `test:smoke`。
|
|
72
|
+
- `tests/integration/<module>/`:会组合多个模块、运行 CLI 子进程、触达临时文件系统或验证模板同步流程,但仍应保持稳定和相对快速,适合 `test:core`。
|
|
73
|
+
- `tests/e2e/<module>/`:较慢的契约、平台同步、打包产物、跨进程或端到端流程测试,只在完整 `npm test` 中运行。
|
|
74
|
+
|
|
75
|
+
模块继续作为第二级目录(如 `cli`、`core`、`scripts`、`templates`)。共享 helper 和 fixtures 保持在 `tests/helpers/`、`tests/helpers.ts`、`tests/fixtures/`,不要放入任一 tier。
|
|
76
|
+
|
|
77
|
+
### 与"测试 tier 覆盖"的关系
|
|
78
|
+
|
|
79
|
+
注意区分两个概念:
|
|
80
|
+
|
|
81
|
+
- **测试 tier 覆盖**(`tests/unit/core/test-tier-coverage.test.ts` 校验测试文件目录归属与 npm script tier 映射):管的是"哪些测试文件被纳入哪一 tier",与代码行覆盖率正交。
|
|
82
|
+
- **代码行覆盖率**(本节):管的是"业务源码哪些行被测试触达"。
|
|
83
|
+
|
|
84
|
+
两者目的不同,不要相互替代。
|
|
@@ -116,6 +116,8 @@ The rule's content is determined by the configured code platform:
|
|
|
116
116
|
- A platform that supports Issue creation: contains the full flow for auth detection, template detection, label/type/milestone inference, the create-Issue call, and writing back to `task.md`
|
|
117
117
|
- Custom or empty platforms (no platform-specific variant provided): the rule body is a no-op notice, and this step is skipped entirely
|
|
118
118
|
|
|
119
|
+
> **Hard constraint**: the milestone sub-step in `.agents/rules/create-issue.md` §4 is mandatory; missing it fails the step 5 gate (`verify_milestone: true`) and aborts create-task.
|
|
120
|
+
|
|
119
121
|
Handle the result:
|
|
120
122
|
- Rule successfully created the Issue: `issue_number` has been written back to task.md per the rule; continue by reading `.agents/rules/issue-sync.md`, completing upstream repository and permission detection, then sync the task comment and set `status: waiting-for-triage` by rule
|
|
121
123
|
- Rule failed (auth / network / template parse / etc.): do not roll back task.md; do NOT append an extra Activity Log entry; follow "Scenario C: Issue creation failed" output to surface `error_code` and `error_message` to the user so they can decide whether to retry manually or write `issue_number` later
|
|
@@ -116,6 +116,8 @@ date "+%Y-%m-%d %H:%M:%S%:z"
|
|
|
116
116
|
- 支持 Issue 创建的平台:包含完整的认证检测、模板检测、label/Issue Type/milestone 推断、Issue 创建调用、`task.md` 回写流程
|
|
117
117
|
- 自定义或空平台(未提供平台变体规则文件):内容为 no-op 说明,本步骤直接跳过
|
|
118
118
|
|
|
119
|
+
> **强约束**:`.agents/rules/create-issue.md` §4 中的 milestone 子步骤为必须执行项;漏设会被步骤 5 的 gate(`verify_milestone: true`)截停,导致 create-task 失败。
|
|
120
|
+
|
|
119
121
|
处理结果:
|
|
120
122
|
- 规则成功创建 Issue:`issue_number` 已按规则回写到 task.md;继续读取 `.agents/rules/issue-sync.md`,完成 upstream 仓库检测和权限检测,然后同步 task 评论并按规则设置 `status: waiting-for-triage`
|
|
121
123
|
- 规则失败(认证 / 网络 / 模板解析等):不回滚 task.md;不追加额外 Activity Log;按"场景 C:Issue 创建失败"输出向用户透出 `error_code` 与 `error_message`,让用户决定后续是否手动重试或写入 `issue_number`
|
|
@@ -114,7 +114,7 @@ If task.md contains a valid `issue_number`, use the Issue update command from `.
|
|
|
114
114
|
|
|
115
115
|
If task.md contains a valid `issue_number`, perform these sync actions (skip and continue on any failure):
|
|
116
116
|
- Read `.agents/rules/issue-sync.md` before syncing, and complete upstream repository detection plus permission detection
|
|
117
|
-
- Check the Issue's current milestone; if it is unset, read `.agents/rules/milestone-inference.md
|
|
117
|
+
- Check the Issue's current milestone; if it is unset, read `.agents/rules/milestone-inference.md`, infer a release line using "Phase 1: `create-task` (when the platform rule creates an Issue)", and run its "Backfill when called from `import-issue`" subsection to write back to the remote Issue. If inference fails, permissions are insufficient, or write-back fails, skip and continue without blocking the import
|
|
118
118
|
- After every scenario, task comment sync is mandatory: create or update the task comment marker defined in `.agents/rules/issue-sync.md` so the remote `:task` comment exists and matches the local `task.md` content (follow the task.md comment sync rule in issue-sync.md)
|
|
119
119
|
|
|
120
120
|
### 7. Verification Gate
|
|
@@ -114,7 +114,7 @@ date "+%Y-%m-%d %H:%M:%S%:z"
|
|
|
114
114
|
|
|
115
115
|
如果 task.md 中存在有效的 `issue_number`,执行以下同步操作(任一失败则跳过并继续):
|
|
116
116
|
- 执行前先读取 `.agents/rules/issue-sync.md`,完成 upstream 仓库检测和权限检测
|
|
117
|
-
- 检查 Issue 当前 milestone;如果未设置,先读取 `.agents/rules/milestone-inference.md`,按其中的「阶段 1:`create-task`(平台规则创建 Issue
|
|
117
|
+
- 检查 Issue 当前 milestone;如果未设置,先读取 `.agents/rules/milestone-inference.md`,按其中的「阶段 1:`create-task`(平台规则创建 Issue 时)」推断版本线,并按其「`import-issue` 调用时的兜底」子节执行远端回写;推断失败、权限不足或回写失败均跳过并继续,不阻断导入
|
|
118
118
|
- 所有场景结束后,必须执行一次 task 留言同步,创建或更新 `.agents/rules/issue-sync.md` 中定义的 task 评论标记,确保远端 `:task` 评论存在且内容与本地 `task.md` 一致(按 issue-sync.md 的 task.md 评论同步规则)
|
|
119
119
|
|
|
120
120
|
### 7. 完成校验
|