@fitlab-ai/agent-infra 0.7.2 → 0.7.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.
Files changed (126) hide show
  1. package/README.md +35 -787
  2. package/README.zh-CN.md +37 -762
  3. package/bin/cli.ts +1 -1
  4. package/dist/bin/cli.js +1 -1
  5. package/dist/lib/defaults.json +0 -1
  6. package/dist/lib/init.js +0 -3
  7. package/dist/lib/sandbox/commands/create.js +44 -3
  8. package/dist/lib/sandbox/commands/enter.js +13 -15
  9. package/dist/lib/sandbox/commands/list-running.js +36 -1
  10. package/dist/lib/sandbox/commands/ls.js +9 -4
  11. package/dist/lib/sandbox/commands/rm.js +99 -19
  12. package/dist/lib/sandbox/commands/start.js +36 -0
  13. package/dist/lib/sandbox/index.js +11 -1
  14. package/dist/lib/sandbox/readme-scaffold.js +6 -6
  15. package/dist/lib/table.js +11 -2
  16. package/dist/lib/task/artifacts.js +58 -0
  17. package/dist/lib/task/commands/cat.js +38 -0
  18. package/dist/lib/task/commands/files.js +47 -0
  19. package/dist/lib/task/commands/grep.js +143 -0
  20. package/dist/lib/task/commands/log.js +75 -0
  21. package/dist/lib/task/commands/ls.js +1 -1
  22. package/dist/lib/task/commands/show.js +5 -114
  23. package/dist/lib/task/commands/status.js +239 -0
  24. package/dist/lib/task/index.js +37 -0
  25. package/dist/lib/task/resolve-ref.js +150 -0
  26. package/dist/lib/task/short-id.js +10 -0
  27. package/dist/lib/update.js +25 -8
  28. package/lib/defaults.json +0 -1
  29. package/lib/init.ts +0 -10
  30. package/lib/sandbox/commands/create.ts +47 -4
  31. package/lib/sandbox/commands/enter.ts +33 -14
  32. package/lib/sandbox/commands/list-running.ts +43 -1
  33. package/lib/sandbox/commands/ls.ts +12 -4
  34. package/lib/sandbox/commands/rm.ts +128 -19
  35. package/lib/sandbox/commands/start.ts +61 -0
  36. package/lib/sandbox/index.ts +11 -1
  37. package/lib/sandbox/readme-scaffold.ts +6 -6
  38. package/lib/table.ts +14 -2
  39. package/lib/task/artifacts.ts +72 -0
  40. package/lib/task/commands/cat.ts +39 -0
  41. package/lib/task/commands/files.ts +53 -0
  42. package/lib/task/commands/grep.ts +147 -0
  43. package/lib/task/commands/log.ts +80 -0
  44. package/lib/task/commands/ls.ts +1 -1
  45. package/lib/task/commands/show.ts +5 -117
  46. package/lib/task/commands/status.ts +302 -0
  47. package/lib/task/index.ts +37 -0
  48. package/lib/task/resolve-ref.ts +160 -0
  49. package/lib/task/short-id.ts +10 -0
  50. package/lib/update.ts +28 -10
  51. package/package.json +1 -1
  52. package/templates/.agents/README.en.md +1 -0
  53. package/templates/.agents/README.zh-CN.md +1 -0
  54. package/templates/.agents/hooks/auto-resume.sh +21 -4
  55. package/templates/.agents/rules/README.en.md +41 -0
  56. package/templates/.agents/rules/README.zh-CN.md +40 -0
  57. package/templates/.agents/rules/debugging-guide.en.md +25 -0
  58. package/templates/.agents/rules/debugging-guide.zh-CN.md +25 -0
  59. package/templates/.agents/rules/next-step-output.en.md +6 -3
  60. package/templates/.agents/rules/next-step-output.zh-CN.md +6 -3
  61. package/templates/.agents/rules/pr-checks-commands.en.md +5 -0
  62. package/templates/.agents/rules/pr-checks-commands.github.en.md +62 -0
  63. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +62 -0
  64. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +5 -0
  65. package/templates/.agents/rules/pr-sync.github.en.md +7 -0
  66. package/templates/.agents/rules/pr-sync.github.zh-CN.md +7 -0
  67. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  68. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  69. package/templates/.agents/skills/block-task/SKILL.en.md +8 -1
  70. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +8 -1
  71. package/templates/.agents/skills/cancel-task/SKILL.en.md +8 -1
  72. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +8 -1
  73. package/templates/.agents/skills/check-task/SKILL.en.md +1 -1
  74. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +1 -1
  75. package/templates/.agents/skills/close-codescan/SKILL.en.md +8 -1
  76. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +8 -1
  77. package/templates/.agents/skills/close-dependabot/SKILL.en.md +8 -1
  78. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +8 -1
  79. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  80. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -1
  81. package/templates/.agents/skills/commit/SKILL.en.md +2 -3
  82. package/templates/.agents/skills/commit/SKILL.zh-CN.md +2 -3
  83. package/templates/.agents/skills/commit/reference/task-status-update.en.md +31 -23
  84. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +31 -23
  85. package/templates/.agents/skills/complete-task/SKILL.en.md +36 -3
  86. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +36 -3
  87. package/templates/.agents/skills/create-pr/SKILL.en.md +16 -7
  88. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +16 -7
  89. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -0
  90. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -0
  91. package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
  92. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  93. package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
  94. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  95. package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
  96. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  97. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  98. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  99. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  100. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -1
  101. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  102. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  103. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  104. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  105. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  106. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  107. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +0 -1
  108. package/templates/.agents/skills/watch-pr/SKILL.en.md +131 -0
  109. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +131 -0
  110. package/templates/.agents/skills/watch-pr/config/verify.json +22 -0
  111. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +43 -0
  112. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +43 -0
  113. package/templates/.agents/templates/task.en.md +1 -0
  114. package/templates/.agents/templates/task.zh-CN.md +1 -0
  115. package/templates/.agents/workflows/bug-fix.en.yaml +6 -4
  116. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +5 -4
  117. package/templates/.agents/workflows/feature-development.en.yaml +6 -4
  118. package/templates/.agents/workflows/feature-development.zh-CN.yaml +5 -4
  119. package/templates/.agents/workflows/refactoring.en.yaml +6 -4
  120. package/templates/.agents/workflows/refactoring.zh-CN.yaml +5 -4
  121. package/templates/.claude/commands/watch-pr.en.md +8 -0
  122. package/templates/.claude/commands/watch-pr.zh-CN.md +8 -0
  123. package/templates/.gemini/commands/_project_/watch-pr.en.toml +8 -0
  124. package/templates/.gemini/commands/_project_/watch-pr.zh-CN.toml +8 -0
  125. package/templates/.opencode/commands/watch-pr.en.md +11 -0
  126. package/templates/.opencode/commands/watch-pr.zh-CN.md +11 -0
@@ -0,0 +1,302 @@
1
+ import fs from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { resolveTaskRef } from '../resolve-ref.ts';
4
+ import { enumerateArtifacts, type Artifact } from '../artifacts.ts';
5
+ import { parseTaskFrontmatter, extractTitle, type Frontmatter } from '../frontmatter.ts';
6
+ import { loadShortIdByTaskId } from '../short-id.ts';
7
+
8
+ const USAGE = `Usage: ai task status <N | #N | TASK-id>
9
+
10
+ Prints an aggregated "health check" view for a task: header, metadata, an
11
+ artifacts summary, git branch state, and best-effort GitHub issue/PR status.
12
+ <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
13
+
14
+ Git and Platform rows are best-effort: a failed git/gh call degrades that row to
15
+ '-' without failing the command.
16
+ `;
17
+
18
+ const DASH = '-';
19
+
20
+ // Subprocess boundary: the single place this command shells out. Injectable so
21
+ // the collectors below can be unit-tested without spawning git/gh. Returns the
22
+ // command's stdout; throws (like execFileSync) on a non-zero exit or spawn error.
23
+ type Runner = (file: string, args: string[]) => string;
24
+
25
+ function makeRunner(cwd: string): Runner {
26
+ return (file, args) =>
27
+ execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
28
+ }
29
+
30
+ // Run `run` and swallow any failure into null, so a single failing git/gh call
31
+ // degrades only its own field instead of aborting the whole view.
32
+ function tryRun(run: Runner, file: string, args: string[]): string | null {
33
+ try {
34
+ return run(file, args);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ // Frontmatter keys shown in the Metadata section, in a fixed display order.
41
+ const METADATA_KEYS = [
42
+ 'type',
43
+ 'status',
44
+ 'current_step',
45
+ 'priority',
46
+ 'effort',
47
+ 'branch',
48
+ 'assigned_to',
49
+ 'created_at',
50
+ 'updated_at',
51
+ 'issue_number',
52
+ 'pr_status'
53
+ ] as const;
54
+
55
+ function collectMetadata(fm: Frontmatter): [string, string][] {
56
+ return METADATA_KEYS.map((key) => [key, fm[key] ? fm[key]! : DASH]);
57
+ }
58
+
59
+ // Workflow stages in timeline order; artifacts are bucketed by filename prefix.
60
+ // `review-*` prefixes are matched before their bare counterparts so that, e.g.,
61
+ // `review-analysis.md` is never swallowed by the `analysis` bucket.
62
+ const STAGE_ORDER = [
63
+ 'analysis',
64
+ 'review-analysis',
65
+ 'plan',
66
+ 'review-plan',
67
+ 'code',
68
+ 'review-code',
69
+ 'task',
70
+ 'other'
71
+ ] as const;
72
+
73
+ function stageOf(name: string): string {
74
+ const stem = name.replace(/\.md$/, '');
75
+ if (stem === 'task') return 'task';
76
+ if (stem.startsWith('review-analysis')) return 'review-analysis';
77
+ if (stem.startsWith('review-plan')) return 'review-plan';
78
+ if (stem.startsWith('review-code')) return 'review-code';
79
+ if (stem.startsWith('analysis')) return 'analysis';
80
+ if (stem.startsWith('plan')) return 'plan';
81
+ if (stem.startsWith('code')) return 'code';
82
+ return 'other';
83
+ }
84
+
85
+ // Group artifacts by workflow stage, preserving the input order (mtime ascending
86
+ // from enumerateArtifacts) within each stage and dropping empty stages.
87
+ function groupArtifacts(artifacts: Artifact[]): { stage: string; files: string[] }[] {
88
+ const byStage = new Map<string, string[]>();
89
+ for (const artifact of artifacts) {
90
+ const stage = stageOf(artifact.name);
91
+ const bucket = byStage.get(stage);
92
+ if (bucket) bucket.push(artifact.name);
93
+ else byStage.set(stage, [artifact.name]);
94
+ }
95
+ const groups: { stage: string; files: string[] }[] = [];
96
+ for (const stage of STAGE_ORDER) {
97
+ const files = byStage.get(stage);
98
+ if (files && files.length > 0) groups.push({ stage, files });
99
+ }
100
+ return groups;
101
+ }
102
+
103
+ type GitInfo = {
104
+ current: string;
105
+ frontmatter: string;
106
+ match: string;
107
+ exists: string;
108
+ uncommitted: string;
109
+ aheadBehind: string;
110
+ };
111
+
112
+ // `frontmatterBranch` is the task.md `branch` field (caller passes '' when absent).
113
+ // It is read straight from frontmatter and never depends on a subprocess, so it
114
+ // keeps its value even when every git call fails. All other fields degrade to '-'
115
+ // on failure of their own command.
116
+ function collectGit(frontmatterBranch: string, run: Runner): GitInfo {
117
+ const frontmatter = frontmatterBranch ? frontmatterBranch : DASH;
118
+
119
+ let current = DASH;
120
+ const cur = tryRun(run, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
121
+ if (cur !== null && cur.trim()) current = cur.trim();
122
+
123
+ let match = DASH;
124
+ if (current !== DASH && frontmatter !== DASH) {
125
+ match = current === frontmatter ? 'yes' : 'no';
126
+ }
127
+
128
+ let exists = DASH;
129
+ if (frontmatter !== DASH) {
130
+ const verified = tryRun(run, 'git', ['rev-parse', '--verify', '--quiet', `refs/heads/${frontmatter}`]);
131
+ exists = verified === null ? 'no' : 'yes';
132
+ }
133
+
134
+ let uncommitted = DASH;
135
+ const porcelain = tryRun(run, 'git', ['status', '--porcelain']);
136
+ if (porcelain !== null) {
137
+ const changed = porcelain.split('\n').filter((line) => line.trim() !== '');
138
+ uncommitted = changed.length === 0 ? 'clean' : `${changed.length} file(s)`;
139
+ }
140
+
141
+ let aheadBehind = DASH;
142
+ if (frontmatter !== DASH) {
143
+ const counts = tryRun(run, 'git', [
144
+ 'rev-list',
145
+ '--left-right',
146
+ '--count',
147
+ `${frontmatter}...${frontmatter}@{upstream}`
148
+ ]);
149
+ if (counts !== null) {
150
+ const parts = counts.trim().split(/\s+/);
151
+ if (parts.length === 2) aheadBehind = `${parts[0]} ahead / ${parts[1]} behind`;
152
+ }
153
+ }
154
+
155
+ return { current, frontmatter, match, exists, uncommitted, aheadBehind };
156
+ }
157
+
158
+ type PlatformInfo = { issue: string; pr: string };
159
+
160
+ function collectPlatform(fm: Frontmatter, run: Runner): PlatformInfo {
161
+ let issue = DASH;
162
+ if (fm.issue_number && /^\d+$/.test(fm.issue_number)) {
163
+ const out = tryRun(run, 'gh', ['issue', 'view', fm.issue_number, '--json', 'state,labels']);
164
+ if (out !== null) {
165
+ try {
166
+ const data = JSON.parse(out);
167
+ const labels = Array.isArray(data.labels)
168
+ ? data.labels.map((label: { name: string }) => label.name).join(', ')
169
+ : '';
170
+ issue = labels ? `${data.state} [${labels}]` : `${data.state}`;
171
+ } catch {
172
+ issue = DASH;
173
+ }
174
+ }
175
+ }
176
+
177
+ let pr = DASH;
178
+ if (fm.pr_status === 'created' && fm.pr_number && /^\d+$/.test(fm.pr_number)) {
179
+ const out = tryRun(run, 'gh', ['pr', 'view', fm.pr_number, '--json', 'state,statusCheckRollup']);
180
+ if (out !== null) {
181
+ try {
182
+ const data = JSON.parse(out);
183
+ const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
184
+ const passed = rollup.filter(
185
+ (check: { conclusion?: string; state?: string }) =>
186
+ check.conclusion === 'SUCCESS' || check.state === 'SUCCESS'
187
+ ).length;
188
+ pr = rollup.length > 0 ? `${data.state}, checks: ${passed}/${rollup.length}` : `${data.state}`;
189
+ } catch {
190
+ pr = DASH;
191
+ }
192
+ }
193
+ }
194
+
195
+ return { issue, pr };
196
+ }
197
+
198
+ type StatusModel = {
199
+ taskId: string;
200
+ shortId: string;
201
+ title: string;
202
+ issueNumber: string;
203
+ metadata: [string, string][];
204
+ artifacts: { count: number; groups: { stage: string; files: string[] }[] };
205
+ git: GitInfo;
206
+ platform: PlatformInfo;
207
+ };
208
+
209
+ // Indent each label/value pair by two spaces and pad labels to a common width so
210
+ // every section reads as an aligned "key value" block.
211
+ function renderPairs(rows: [string, string][]): string[] {
212
+ const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
213
+ return rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`.trimEnd());
214
+ }
215
+
216
+ function renderStatus(model: StatusModel): string[] {
217
+ const lines: string[] = [];
218
+
219
+ lines.push(`Task ${model.taskId} (${model.shortId})`);
220
+ if (model.title) lines.push(model.title);
221
+
222
+ lines.push('', 'Metadata', ...renderPairs(model.metadata));
223
+
224
+ lines.push('', `Artifacts (${model.artifacts.count})`);
225
+ if (model.artifacts.groups.length === 0) {
226
+ lines.push(' (none)');
227
+ } else {
228
+ lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
229
+ }
230
+
231
+ lines.push(
232
+ '',
233
+ 'Git',
234
+ ...renderPairs([
235
+ ['current', model.git.current],
236
+ ['frontmatter', model.git.frontmatter],
237
+ ['match', model.git.match],
238
+ ['exists', model.git.exists],
239
+ ['uncommitted', model.git.uncommitted],
240
+ ['ahead/behind', model.git.aheadBehind]
241
+ ])
242
+ );
243
+
244
+ const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
245
+ lines.push(
246
+ '',
247
+ 'Platform',
248
+ ...renderPairs([
249
+ [issueLabel, model.platform.issue],
250
+ ['pr', model.platform.pr]
251
+ ])
252
+ );
253
+
254
+ return lines;
255
+ }
256
+
257
+ function status(args: string[] = []): void {
258
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
259
+ process.stdout.write(USAGE);
260
+ if (args.length === 0) process.exitCode = 1;
261
+ return;
262
+ }
263
+
264
+ const resolved = resolveTaskRef(args[0]!);
265
+ if (!resolved.ok) {
266
+ process.stderr.write(`ai task status: ${resolved.message}\n`);
267
+ process.exitCode = 1;
268
+ return;
269
+ }
270
+
271
+ const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
272
+ const fm = parseTaskFrontmatter(content);
273
+ const run = makeRunner(resolved.repoRoot);
274
+ const artifacts = enumerateArtifacts(resolved.taskDir);
275
+
276
+ const model: StatusModel = {
277
+ taskId: resolved.taskId,
278
+ shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
279
+ title: extractTitle(content),
280
+ issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
281
+ metadata: collectMetadata(fm),
282
+ artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
283
+ git: collectGit(fm.branch ?? '', run),
284
+ platform: collectPlatform(fm, run)
285
+ };
286
+
287
+ for (const line of renderStatus(model)) {
288
+ process.stdout.write(`${line}\n`);
289
+ }
290
+ }
291
+
292
+ export {
293
+ status,
294
+ makeRunner,
295
+ collectMetadata,
296
+ groupArtifacts,
297
+ collectGit,
298
+ collectPlatform,
299
+ renderStatus,
300
+ METADATA_KEYS
301
+ };
302
+ export type { Runner, GitInfo, PlatformInfo, StatusModel };
package/lib/task/index.ts CHANGED
@@ -1,13 +1,25 @@
1
1
  const USAGE = `Usage: ai task <command> [options]
2
2
 
3
3
  Commands:
4
+ cat <ref> <artifact | N> Print a task artifact (by name or number)
5
+ files <ref> List artifacts in a task dir (numbered)
6
+ grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
7
+ log <ref> Render a task's activity log as a timeline
4
8
  ls [--all | --blocked | --completed] List tasks (default: active)
5
9
  show <N | #N | TASK-id> Print a task.md
10
+ status <ref> Aggregated status view (metadata / artifacts / git / platform)
6
11
 
7
12
  Examples:
13
+ ai task cat 11 analysis
14
+ ai task cat 11 3
15
+ ai task files 11
16
+ ai task grep resolveArtifact
17
+ ai task grep resolveArtifact 11
18
+ ai task log 11
8
19
  ai task ls
9
20
  ai task show 11
10
21
  ai task show TASK-20260612-162737
22
+ ai task status 11
11
23
 
12
24
  Run 'ai task <command> --help' for details.`;
13
25
 
@@ -36,6 +48,31 @@ export async function runTask(args: string[]): Promise<void> {
36
48
  show(rest);
37
49
  break;
38
50
  }
51
+ case 'files': {
52
+ const { files } = await import('./commands/files.ts');
53
+ files(rest);
54
+ break;
55
+ }
56
+ case 'cat': {
57
+ const { cat } = await import('./commands/cat.ts');
58
+ cat(rest);
59
+ break;
60
+ }
61
+ case 'grep': {
62
+ const { grep } = await import('./commands/grep.ts');
63
+ grep(rest);
64
+ break;
65
+ }
66
+ case 'log': {
67
+ const { log } = await import('./commands/log.ts');
68
+ log(rest);
69
+ break;
70
+ }
71
+ case 'status': {
72
+ const { status } = await import('./commands/status.ts');
73
+ status(rest);
74
+ break;
75
+ }
39
76
  default:
40
77
  process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
41
78
  process.stdout.write(`${USAGE}\n`);
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { normalizeShortIdInput } from './short-id.ts';
5
+
6
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
7
+ // Flat-structured workspace dirs that hold tasks under `{dir}/{taskId}/task.md`.
8
+ // Note: `archive` uses a three-level YYYY/MM/DD layout and is handled separately.
9
+ const FLAT_WORKSPACE_DIRS = ['active', 'blocked', 'completed'] as const;
10
+
11
+ type ResolveRefResult =
12
+ | {
13
+ ok: true;
14
+ repoRoot: string;
15
+ taskId: string;
16
+ taskDir: string;
17
+ taskMdPath: string;
18
+ }
19
+ | { ok: false; message: string };
20
+
21
+ function detectRepoRoot(): string {
22
+ try {
23
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
24
+ encoding: 'utf8',
25
+ stdio: ['pipe', 'pipe', 'pipe']
26
+ }).trim();
27
+ } catch {
28
+ throw new Error('ai task: current directory is not inside a git repository');
29
+ }
30
+ }
31
+
32
+ function readShortIdLength(repoRoot: string): number {
33
+ try {
34
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
35
+ const v = cfg?.task?.shortIdLength;
36
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 1) return v;
37
+ } catch {
38
+ // fall through to default
39
+ }
40
+ return 2;
41
+ }
42
+
43
+ function resolveShortIdToTaskId(arg: string, repoRoot: string): string {
44
+ const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
45
+ if (!fs.existsSync(scriptPath)) {
46
+ throw new Error(`task-short-id.js not found at ${scriptPath}`);
47
+ }
48
+ const result = spawnSync('node', [scriptPath, 'resolve', arg], {
49
+ encoding: 'utf8',
50
+ cwd: repoRoot
51
+ });
52
+ if (result.status !== 0) {
53
+ throw new Error((result.stderr || '').trim() || `failed to resolve '${arg}'`);
54
+ }
55
+ return result.stdout.trim();
56
+ }
57
+
58
+ function listSortedNumeric(dir: string, width: number): string[] {
59
+ if (!fs.existsSync(dir)) return [];
60
+ const pattern = new RegExp(`^\\d{${width}}$`);
61
+ return fs
62
+ .readdirSync(dir)
63
+ .filter((entry) => pattern.test(entry))
64
+ .sort()
65
+ .reverse();
66
+ }
67
+
68
+ function findInArchive(repoRoot: string, taskId: string): string | null {
69
+ // archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
70
+ // where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
71
+ // the task id's creation date. So we cannot derive the path from taskId alone;
72
+ // walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
73
+ const archiveDir = path.join(repoRoot, '.agents', 'workspace', 'archive');
74
+ for (const year of listSortedNumeric(archiveDir, 4)) {
75
+ const yearDir = path.join(archiveDir, year);
76
+ for (const month of listSortedNumeric(yearDir, 2)) {
77
+ const monthDir = path.join(yearDir, month);
78
+ for (const day of listSortedNumeric(monthDir, 2)) {
79
+ const candidate = path.join(monthDir, day, taskId, 'task.md');
80
+ if (fs.existsSync(candidate)) return candidate;
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function findTaskMd(repoRoot: string, taskId: string): string | null {
88
+ for (const sub of FLAT_WORKSPACE_DIRS) {
89
+ const candidate = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
90
+ if (fs.existsSync(candidate)) return candidate;
91
+ }
92
+ return findInArchive(repoRoot, taskId);
93
+ }
94
+
95
+ /**
96
+ * Enumerate every task directory under the flat workspace states
97
+ * (active / blocked / completed) — archive is intentionally excluded so a
98
+ * full-tree scan never pulls in cold data. Ordered by state, then task id
99
+ * ascending, giving callers a deterministic traversal.
100
+ */
101
+ function enumerateTaskDirs(repoRoot: string): { taskId: string; taskDir: string }[] {
102
+ const out: { taskId: string; taskDir: string }[] = [];
103
+ for (const sub of FLAT_WORKSPACE_DIRS) {
104
+ const base = path.join(repoRoot, '.agents', 'workspace', sub);
105
+ if (!fs.existsSync(base)) continue;
106
+ for (const entry of fs.readdirSync(base).sort()) {
107
+ if (!TASK_ID_RE.test(entry)) continue;
108
+ const taskDir = path.join(base, entry);
109
+ if (!fs.existsSync(path.join(taskDir, 'task.md'))) continue;
110
+ out.push({ taskId: entry, taskDir });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Resolve a task ref (bare short id, `#N`, or `TASK-YYYYMMDD-HHMMSS`) to its
118
+ * task directory across active / blocked / completed / archive.
119
+ *
120
+ * The returned `message` on failure is command-agnostic (no `ai task <cmd>:`
121
+ * prefix); callers prepend their own prefix so each command keeps its existing
122
+ * stderr wording byte-for-byte.
123
+ */
124
+ function resolveTaskRef(arg: string): ResolveRefResult {
125
+ const repoRoot = detectRepoRoot();
126
+ let taskId: string;
127
+ if (TASK_ID_RE.test(arg)) {
128
+ taskId = arg;
129
+ } else {
130
+ const shortIdLength = readShortIdLength(repoRoot);
131
+ const normalized = normalizeShortIdInput(arg, { shortIdLength });
132
+ if (normalized.kind === 'error') {
133
+ return { ok: false, message: normalized.message };
134
+ }
135
+ if (normalized.kind === 'pass') {
136
+ return {
137
+ ok: false,
138
+ message:
139
+ `'${arg}' is not a valid short id or TASK-id; ` +
140
+ `expected bare digits, '#N', or 'TASK-YYYYMMDD-HHMMSS'`
141
+ };
142
+ }
143
+ try {
144
+ taskId = resolveShortIdToTaskId(normalized.value, repoRoot);
145
+ } catch (e) {
146
+ return { ok: false, message: (e as Error).message };
147
+ }
148
+ }
149
+ const taskMdPath = findTaskMd(repoRoot, taskId);
150
+ if (!taskMdPath) {
151
+ return {
152
+ ok: false,
153
+ message: `task ${taskId} not found in active / blocked / completed / archive`
154
+ };
155
+ }
156
+ return { ok: true, repoRoot, taskId, taskDir: path.dirname(taskMdPath), taskMdPath };
157
+ }
158
+
159
+ export { resolveTaskRef, detectRepoRoot, enumerateTaskDirs, TASK_ID_RE };
160
+ export type { ResolveRefResult };
@@ -70,6 +70,16 @@ function loadShortIdByTaskId(repoRoot: string): Map<string, string> {
70
70
  return map;
71
71
  }
72
72
 
73
+ /**
74
+ * Resolve a branch to its active-task short id (`#NN`), or `null` when no
75
+ * active task is bound to that branch.
76
+ *
77
+ * Two-state semantics: this only consults the active registry
78
+ * (`active/.short-ids.json`) plus each `active/{taskId}/task.md`. Tasks moved
79
+ * to completed/blocked/cancelled/archive have already released their short id,
80
+ * so their branches return `null` — in `ai sandbox ls` that surfaces as `-`,
81
+ * meaning the sandbox is free to remove.
82
+ */
73
83
  function lookupShortIdByBranch(
74
84
  branch: string,
75
85
  repoRoot: string,
package/lib/update.ts CHANGED
@@ -17,7 +17,8 @@ type UpdateConfig = {
17
17
  org: string;
18
18
  language: string;
19
19
  platform?: { type?: string };
20
- requiresPullRequest?: boolean;
20
+ requiresPullRequest?: boolean; // legacy field; read-only, migrated to prFlow then removed
21
+ prFlow?: 'required' | 'disabled';
21
22
  sandbox?: Record<string, unknown>;
22
23
  task?: { shortIdLength: number };
23
24
  labels?: Record<string, unknown>;
@@ -27,7 +28,6 @@ type UpdateConfig = {
27
28
 
28
29
  type Defaults = {
29
30
  platform: { type: string };
30
- requiresPullRequest: boolean;
31
31
  sandbox: Record<string, unknown>;
32
32
  task: { shortIdLength: number };
33
33
  labels: Record<string, unknown>;
@@ -41,6 +41,25 @@ const defaults = JSON.parse(
41
41
  const CONFIG_DIR = '.agents';
42
42
  const CONFIG_PATH = path.join(CONFIG_DIR, '.airc.json');
43
43
 
44
+ // One-time migration of the legacy project-level PR switch to the three-state
45
+ // `prFlow` preference. `true` (the old default / "PR flow on") maps to the
46
+ // strong constraint `required`; `false` maps to `disabled`. A missing or
47
+ // already-migrated config is left untouched (idempotent). Returns the new
48
+ // prFlow value when a migration happened, otherwise null.
49
+ function migratePrFlow(config: UpdateConfig): 'required' | 'disabled' | null {
50
+ if (config.requiresPullRequest === true) {
51
+ delete config.requiresPullRequest;
52
+ config.prFlow = 'required';
53
+ return 'required';
54
+ }
55
+ if (config.requiresPullRequest === false) {
56
+ delete config.requiresPullRequest;
57
+ config.prFlow = 'disabled';
58
+ return 'disabled';
59
+ }
60
+ return null;
61
+ }
62
+
44
63
  function isPathOwnedByOtherPlatform(relativePath: string, platformType: string): boolean {
45
64
  const top = String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '').split('/')[0] ?? '';
46
65
  if (!top.startsWith('.')) return false;
@@ -195,7 +214,7 @@ async function cmdUpdate(): Promise<void> {
195
214
  const sandboxAdded = !config.sandbox;
196
215
  const taskAdded = !config.task;
197
216
  const labelsAdded = !config.labels;
198
- const requiresPullRequestAdded = config.requiresPullRequest === undefined;
217
+ const prFlowMigrated = migratePrFlow(config);
199
218
  let configChanged = changed;
200
219
 
201
220
  if (platformAdded) {
@@ -218,8 +237,7 @@ async function cmdUpdate(): Promise<void> {
218
237
  configChanged = true;
219
238
  }
220
239
 
221
- if (requiresPullRequestAdded) {
222
- config.requiresPullRequest = defaults.requiresPullRequest;
240
+ if (prFlowMigrated) {
223
241
  configChanged = true;
224
242
  }
225
243
 
@@ -233,7 +251,7 @@ async function cmdUpdate(): Promise<void> {
233
251
  for (const entry of added.merged) {
234
252
  ok(` merged: ${entry}`);
235
253
  }
236
- } else if (platformAdded || sandboxAdded || taskAdded || labelsAdded || requiresPullRequestAdded) {
254
+ } else if (platformAdded || sandboxAdded || taskAdded || labelsAdded || prFlowMigrated) {
237
255
  if (platformAdded) {
238
256
  info(`Default platform config added to ${CONFIG_PATH}.`);
239
257
  }
@@ -246,8 +264,8 @@ async function cmdUpdate(): Promise<void> {
246
264
  if (labelsAdded) {
247
265
  info(`Default labels.in config added to ${CONFIG_PATH}.`);
248
266
  }
249
- if (requiresPullRequestAdded) {
250
- info(`Default requiresPullRequest=${defaults.requiresPullRequest} added to ${CONFIG_PATH}.`);
267
+ if (prFlowMigrated) {
268
+ info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
251
269
  }
252
270
  } else {
253
271
  info(`File registry changed in ${CONFIG_PATH}.`);
@@ -264,8 +282,8 @@ async function cmdUpdate(): Promise<void> {
264
282
  if (hasNewEntries && platformAdded) {
265
283
  info(`Default platform config added to ${CONFIG_PATH}.`);
266
284
  }
267
- if (hasNewEntries && requiresPullRequestAdded) {
268
- info(`Default requiresPullRequest=${defaults.requiresPullRequest} added to ${CONFIG_PATH}.`);
285
+ if (hasNewEntries && prFlowMigrated) {
286
+ info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
269
287
  }
270
288
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
271
289
  ok(`Updated ${CONFIG_PATH}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.7.2",
3
+ "version": "0.7.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",
@@ -34,6 +34,7 @@ This dual-config approach ensures every AI tool receives appropriate project con
34
34
  bug-fix.yaml # Bug fix workflow
35
35
  code-review.yaml # Code review workflow
36
36
  refactoring.yaml # Refactoring workflow
37
+ rules/ # Collaboration rule index (see rules/README.md)
37
38
  workspace/ # Runtime workspace (git-ignored)
38
39
  active/ # Currently active tasks
39
40
  blocked/ # Blocked tasks
@@ -34,6 +34,7 @@
34
34
  bug-fix.yaml # 缺陷修复工作流
35
35
  code-review.yaml # 代码审查工作流
36
36
  refactoring.yaml # 重构工作流
37
+ rules/ # 协作规则索引(见 rules/README.md)
37
38
  workspace/ # 运行时工作区(已被 git ignore)
38
39
  active/ # 当前活跃任务
39
40
  blocked/ # 被阻塞的任务
@@ -80,8 +80,25 @@ until curl -s -o /dev/null --max-time 3 "$PROBE_URL"; do
80
80
  done
81
81
  log "probe ok after ${waited}s (error=$error)"
82
82
 
83
- # Inject: Escape first to leave any non-input TUI state, then the resume text.
84
- tmux send-keys -t "$TMUX_PANE" Escape 2>/dev/null
85
- tmux send-keys -t "$TMUX_PANE" "$RESUME_TEXT" Enter 2>/dev/null
86
- log "send-keys done (error=$error)"
83
+ # Inject the resume message with a deliberately timing-insensitive sequence:
84
+ # 1. Escape leaves any non-input TUI state.
85
+ # 2. A 1s settle covers every known TUI escape timeout (vim 1000ms,
86
+ # xterm/readline 50ms) so the next bytes are delivered as fresh input
87
+ # instead of being folded into the escape sequence (the dropped-`U` race).
88
+ # 3. The text travels through a NAMED paste buffer pasted with bracketed
89
+ # paste (-p): the TUI ingests it as a single paste rather than per-character
90
+ # keypresses, so no leading char is eaten and the body is not read as a
91
+ # submit. The named buffer (-b) guarantees we paste exactly this text, and
92
+ # -d deletes it afterward so the user's anonymous paste stack is untouched.
93
+ # 4. Enter is a separate send-keys after the paste, so the submit signal is
94
+ # never merged into the pasted content (the must-press-Enter race).
95
+ # Every step stays non-blocking (2>/dev/null, exit 0 below) and logs a WARN on
96
+ # failure so the log can localize which tmux step broke.
97
+ log "tmux inject start (error=$error)"
98
+ tmux send-keys -t "$TMUX_PANE" Escape 2>/dev/null || log "WARN: tmux Escape failed (error=$error)"
99
+ sleep 1
100
+ tmux set-buffer -b auto-resume -- "$RESUME_TEXT" 2>/dev/null || log "WARN: tmux set-buffer failed (error=$error)"
101
+ tmux paste-buffer -t "$TMUX_PANE" -b auto-resume -p -d 2>/dev/null || log "WARN: tmux paste-buffer failed (error=$error)"
102
+ tmux send-keys -t "$TMUX_PANE" Enter 2>/dev/null || log "WARN: tmux Enter failed (error=$error)"
103
+ log "tmux inject done (error=$error)"
87
104
  exit 0