@fitlab-ai/agent-infra 0.7.6 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +11 -3
- package/dist/bin/cli.js +10 -3
- package/dist/lib/sandbox/commands/ls.js +4 -33
- package/dist/lib/sandbox/commands/show.js +67 -0
- package/dist/lib/sandbox/index.js +7 -0
- package/dist/lib/task/commands/issue-body.js +94 -0
- package/dist/lib/task/commands/log.js +92 -9
- package/dist/lib/task/index.js +8 -0
- package/dist/lib/task/issue-form.js +66 -0
- package/dist/lib/task/sections.js +44 -0
- package/lib/sandbox/commands/ls.ts +4 -36
- package/lib/sandbox/commands/show.ts +80 -0
- package/lib/sandbox/index.ts +7 -0
- package/lib/task/commands/issue-body.ts +102 -0
- package/lib/task/commands/log.ts +96 -9
- package/lib/task/index.ts +8 -0
- package/lib/task/issue-form.ts +77 -0
- package/lib/task/sections.ts +44 -0
- package/package.json +3 -2
- package/templates/.agents/README.en.md +3 -1
- package/templates/.agents/README.zh-CN.md +3 -1
- package/templates/.agents/rules/create-issue.github.en.md +20 -29
- package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
- package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
- package/templates/.agents/rules/next-step-output.en.md +26 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +26 -1
- package/templates/.agents/rules/task-management.en.md +2 -0
- package/templates/.agents/rules/task-management.zh-CN.md +2 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
- package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
- package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
- package/templates/.agents/skills/commit/SKILL.en.md +27 -5
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
- package/templates/.agents/skills/complete-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/create-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -1
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +19 -2
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +19 -2
- package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
- package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
- package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
- package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/release/SKILL.en.md +3 -1
- package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/test/SKILL.en.md +3 -1
- package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
- package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
- package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
- package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
package/bin/cli.ts
CHANGED
|
@@ -19,8 +19,8 @@ Commands:
|
|
|
19
19
|
help Show this help message
|
|
20
20
|
init Initialize a new project with update-agent-infra seed command
|
|
21
21
|
merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
22
|
-
sandbox
|
|
23
|
-
task
|
|
22
|
+
sandbox, s Manage Docker-based AI sandboxes
|
|
23
|
+
task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
|
|
24
24
|
update Update seed files and sync file registry for an existing project
|
|
25
25
|
version Show version
|
|
26
26
|
|
|
@@ -37,7 +37,15 @@ Examples:
|
|
|
37
37
|
npx @fitlab-ai/agent-infra init
|
|
38
38
|
`;
|
|
39
39
|
|
|
40
|
-
const
|
|
40
|
+
const COMMAND_ALIASES: Record<string, string> = {
|
|
41
|
+
s: 'sandbox',
|
|
42
|
+
t: 'task'
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const rawCommand = process.argv[2] || '';
|
|
46
|
+
const command = Object.hasOwn(COMMAND_ALIASES, rawCommand)
|
|
47
|
+
? COMMAND_ALIASES[rawCommand]
|
|
48
|
+
: rawCommand;
|
|
41
49
|
|
|
42
50
|
function errorMessage(error: unknown): string {
|
|
43
51
|
return error instanceof Error ? error.message : String(error);
|
package/dist/bin/cli.js
CHANGED
|
@@ -23,8 +23,8 @@ Commands:
|
|
|
23
23
|
help Show this help message
|
|
24
24
|
init Initialize a new project with update-agent-infra seed command
|
|
25
25
|
merge Merge tasks from another workspace directory (active/blocked/completed/archive)
|
|
26
|
-
sandbox
|
|
27
|
-
task
|
|
26
|
+
sandbox, s Manage Docker-based AI sandboxes
|
|
27
|
+
task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
|
|
28
28
|
update Update seed files and sync file registry for an existing project
|
|
29
29
|
version Show version
|
|
30
30
|
|
|
@@ -40,7 +40,14 @@ Examples:
|
|
|
40
40
|
cd my-project && ai init
|
|
41
41
|
npx @fitlab-ai/agent-infra init
|
|
42
42
|
`;
|
|
43
|
-
const
|
|
43
|
+
const COMMAND_ALIASES = {
|
|
44
|
+
s: 'sandbox',
|
|
45
|
+
t: 'task'
|
|
46
|
+
};
|
|
47
|
+
const rawCommand = process.argv[2] || '';
|
|
48
|
+
const command = Object.hasOwn(COMMAND_ALIASES, rawCommand)
|
|
49
|
+
? COMMAND_ALIASES[rawCommand]
|
|
50
|
+
: rawCommand;
|
|
44
51
|
function errorMessage(error) {
|
|
45
52
|
return error instanceof Error ? error.message : String(error);
|
|
46
53
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import * as p from '@clack/prompts';
|
|
4
2
|
import pc from 'picocolors';
|
|
5
3
|
import { loadConfig } from "../config.js";
|
|
6
4
|
import { sandboxBranchLabel, sandboxLabel } from "../constants.js";
|
|
7
5
|
import { detectEngine } from "../engine.js";
|
|
8
|
-
import { resolveTools, toolProjectDirCandidates } from "../tools.js";
|
|
9
6
|
import { formatTable } from "../../table.js";
|
|
10
7
|
import { lookupShortIdByBranch } from "../../task/short-id.js";
|
|
11
8
|
import { fetchSandboxRows } from "./list-running.js";
|
|
@@ -18,17 +15,14 @@ id bound to each container's branch (via
|
|
|
18
15
|
.agents/workspace/active/.short-ids.json), or '-' if no active task is
|
|
19
16
|
bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
|
|
20
17
|
A '-' means no active task is bound to that branch, so the sandbox is free
|
|
21
|
-
to remove with "ai sandbox rm <branch>"
|
|
18
|
+
to remove with "ai sandbox rm <branch>".
|
|
19
|
+
|
|
20
|
+
Use "ai sandbox show <ref>" for a single sandbox's worktree and per-tool
|
|
21
|
+
state paths.`;
|
|
22
22
|
const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'];
|
|
23
23
|
export function formatContainerTable(rows, zebra = false) {
|
|
24
24
|
return formatTable(CONTAINER_TABLE_HEADERS, rows.map((r) => [r.row, r.shortId, r.name, r.status, r.branch]), { zebra });
|
|
25
25
|
}
|
|
26
|
-
function listChildren(dir) {
|
|
27
|
-
if (!fs.existsSync(dir)) {
|
|
28
|
-
return [];
|
|
29
|
-
}
|
|
30
|
-
return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
|
|
31
|
-
}
|
|
32
26
|
export function ls(args = []) {
|
|
33
27
|
if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
|
|
34
28
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -36,7 +30,6 @@ export function ls(args = []) {
|
|
|
36
30
|
}
|
|
37
31
|
const config = loadConfig();
|
|
38
32
|
const engine = detectEngine(config);
|
|
39
|
-
const tools = resolveTools(config);
|
|
40
33
|
const label = sandboxLabel(config);
|
|
41
34
|
const { running, nonRunning } = fetchSandboxRows(engine, label, sandboxBranchLabel(config));
|
|
42
35
|
p.intro(pc.cyan(`Sandbox status for ${config.project}`));
|
|
@@ -64,27 +57,5 @@ export function ls(args = []) {
|
|
|
64
57
|
process.stdout.write(` SHORT '-' = no active task bound; that sandbox is free to remove with 'ai sandbox rm <branch>'.\n`);
|
|
65
58
|
}
|
|
66
59
|
}
|
|
67
|
-
p.log.step('Worktrees');
|
|
68
|
-
const worktrees = listChildren(config.worktreeBase);
|
|
69
|
-
if (worktrees.length === 0) {
|
|
70
|
-
p.log.warn(' No sandbox worktrees');
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
for (const worktree of worktrees) {
|
|
74
|
-
process.stdout.write(` ${worktree}\n`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
for (const tool of tools) {
|
|
78
|
-
p.log.step(`${tool.name} state`);
|
|
79
|
-
const entries = toolProjectDirCandidates(tool, config.project)
|
|
80
|
-
.flatMap((dir) => listChildren(dir));
|
|
81
|
-
if (entries.length === 0) {
|
|
82
|
-
p.log.warn(` No ${tool.name} sandbox state`);
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
for (const entry of entries) {
|
|
86
|
-
process.stdout.write(` ${entry}\n`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
60
|
}
|
|
90
61
|
//# sourceMappingURL=ls.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { assertValidBranchName, worktreeDirCandidates } from "../constants.js";
|
|
6
|
+
import { resolveBranchArg } from "./list-running.js";
|
|
7
|
+
import { resolveTools, toolConfigDirCandidates } from "../tools.js";
|
|
8
|
+
const USAGE = `Usage: ai sandbox show <branch | TASK-id | N | '#N'>
|
|
9
|
+
|
|
10
|
+
Shows one sandbox's worktree path and per-tool state paths (Claude Code,
|
|
11
|
+
Codex, Gemini CLI, OpenCode). The argument follows the same contract as
|
|
12
|
+
'ai sandbox exec' and 'ai sandbox start': N (bare) is the recommended form
|
|
13
|
+
for task short ids (e.g. 'ai sandbox show 11'); '#N', a TASK-id, or a plain
|
|
14
|
+
branch name are also accepted. Use 'ai sandbox ls' for the container list.`;
|
|
15
|
+
function existingDirs(candidates) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const result = [];
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
if (!seen.has(candidate) && fs.existsSync(candidate)) {
|
|
20
|
+
seen.add(candidate);
|
|
21
|
+
result.push(candidate);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
export function collectSandboxDetail(config, branch) {
|
|
27
|
+
const worktrees = existingDirs(worktreeDirCandidates(config, branch));
|
|
28
|
+
const toolStates = resolveTools(config).map((tool) => ({
|
|
29
|
+
name: tool.name,
|
|
30
|
+
entries: existingDirs(toolConfigDirCandidates(tool, config.project, branch))
|
|
31
|
+
}));
|
|
32
|
+
return { worktrees, toolStates };
|
|
33
|
+
}
|
|
34
|
+
export function show(args = []) {
|
|
35
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
36
|
+
process.stdout.write(`${USAGE}\n`);
|
|
37
|
+
if (args.length === 0) {
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const config = loadConfig();
|
|
43
|
+
const branch = resolveBranchArg(args[0], { repoRoot: config.repoRoot });
|
|
44
|
+
assertValidBranchName(branch);
|
|
45
|
+
const detail = collectSandboxDetail(config, branch);
|
|
46
|
+
p.intro(pc.cyan(`Sandbox detail for ${config.project} · ${branch}`));
|
|
47
|
+
p.log.step('Worktree');
|
|
48
|
+
if (detail.worktrees.length === 0) {
|
|
49
|
+
p.log.warn(' No worktree for this branch');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
for (const worktree of detail.worktrees) {
|
|
53
|
+
process.stdout.write(` ${worktree}\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const tool of detail.toolStates) {
|
|
57
|
+
p.log.step(`${tool.name} state`);
|
|
58
|
+
if (tool.entries.length === 0) {
|
|
59
|
+
p.log.warn(` No ${tool.name} sandbox state`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const entry of tool.entries) {
|
|
63
|
+
process.stdout.write(` ${entry}\n`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=show.js.map
|
|
@@ -9,6 +9,8 @@ Commands:
|
|
|
9
9
|
ls List sandboxes for the current project (the '#'
|
|
10
10
|
column is a display-only row number; the 'SHORT'
|
|
11
11
|
column shows the active task short id, '-' if none)
|
|
12
|
+
show <branch | TASK-id | N | '#N'>
|
|
13
|
+
Show one sandbox's worktree and per-tool state paths
|
|
12
14
|
prune [--dry-run] Remove orphaned per-branch state dirs
|
|
13
15
|
rebuild [--quiet] [--refresh]
|
|
14
16
|
Rebuild the sandbox image (--refresh pulls base + tools)
|
|
@@ -52,6 +54,11 @@ export async function runSandbox(args) {
|
|
|
52
54
|
ls(rest);
|
|
53
55
|
break;
|
|
54
56
|
}
|
|
57
|
+
case 'show': {
|
|
58
|
+
const { show } = await import("./commands/show.js");
|
|
59
|
+
show(rest);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
55
62
|
case 'prune': {
|
|
56
63
|
const { prune } = await import("./commands/prune.js");
|
|
57
64
|
await prune(rest);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { resolveTaskRef } from "../resolve-ref.js";
|
|
3
|
+
import { extractTitle } from "../frontmatter.js";
|
|
4
|
+
import { extractSection, findSectionHeading } from "../sections.js";
|
|
5
|
+
import { renderTemplateBody, PLACEHOLDER } from "../issue-form.js";
|
|
6
|
+
const USAGE = `Usage: ai task issue-body <N | #N | TASK-id> [--template <path>]
|
|
7
|
+
|
|
8
|
+
Print a deterministic Issue body extracted from a task's task.md.
|
|
9
|
+
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
|
|
10
|
+
--template <path> Render the final body for the given GitHub Issue Form (scenario A);
|
|
11
|
+
without it, print the default '描述 + 需求' body (scenario B).
|
|
12
|
+
|
|
13
|
+
Only the task title, '## 描述' and '## 需求' sections are ever emitted; the rest of
|
|
14
|
+
task.md (scaffolding sections, placeholders) is never written to the body.
|
|
15
|
+
`;
|
|
16
|
+
const DESCRIPTION_ALIASES = ['描述', 'Description'];
|
|
17
|
+
const REQUIREMENTS_ALIASES = ['需求', 'Requirements'];
|
|
18
|
+
/**
|
|
19
|
+
* Build the scenario B default body, mirroring whichever heading language the
|
|
20
|
+
* task.md actually uses, with empty sections falling back to `N/A`.
|
|
21
|
+
*/
|
|
22
|
+
function buildDefaultBody(content) {
|
|
23
|
+
const descHeading = findSectionHeading(content, DESCRIPTION_ALIASES);
|
|
24
|
+
const reqHeading = findSectionHeading(content, REQUIREMENTS_ALIASES);
|
|
25
|
+
const description = extractSection(content, DESCRIPTION_ALIASES) || PLACEHOLDER;
|
|
26
|
+
const requirements = extractSection(content, REQUIREMENTS_ALIASES) || PLACEHOLDER;
|
|
27
|
+
return `## ${descHeading}\n\n${description}\n\n## ${reqHeading}\n\n${requirements}\n`;
|
|
28
|
+
}
|
|
29
|
+
function readTaskFields(content) {
|
|
30
|
+
return {
|
|
31
|
+
title: extractTitle(content),
|
|
32
|
+
description: extractSection(content, DESCRIPTION_ALIASES),
|
|
33
|
+
requirements: extractSection(content, REQUIREMENTS_ALIASES)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function issueBody(args = []) {
|
|
37
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
38
|
+
process.stdout.write(USAGE);
|
|
39
|
+
if (args.length === 0)
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let ref;
|
|
44
|
+
let templatePath;
|
|
45
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg === '--template') {
|
|
48
|
+
templatePath = args[i + 1];
|
|
49
|
+
i += 1;
|
|
50
|
+
}
|
|
51
|
+
else if (ref === undefined) {
|
|
52
|
+
ref = arg;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!ref) {
|
|
56
|
+
process.stderr.write('ai task issue-body: missing task ref\n');
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (templatePath === undefined && args.includes('--template')) {
|
|
61
|
+
process.stderr.write('ai task issue-body: --template requires a path\n');
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const resolved = resolveTaskRef(ref);
|
|
66
|
+
if (!resolved.ok) {
|
|
67
|
+
process.stderr.write(`ai task issue-body: ${resolved.message}\n`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
72
|
+
if (templatePath !== undefined) {
|
|
73
|
+
let formText;
|
|
74
|
+
try {
|
|
75
|
+
formText = fs.readFileSync(templatePath, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
process.stderr.write(`ai task issue-body: cannot read template '${templatePath}': ${e.message}\n`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
process.stdout.write(renderTemplateBody(formText, readTaskFields(content)));
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
process.stderr.write(`ai task issue-body: cannot render template '${templatePath}': ${e.message}\n`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write(buildDefaultBody(content));
|
|
92
|
+
}
|
|
93
|
+
export { issueBody, buildDefaultBody };
|
|
94
|
+
//# sourceMappingURL=issue-body.js.map
|
|
@@ -9,10 +9,15 @@ completion time (or '(in progress)' while still running).
|
|
|
9
9
|
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
|
|
10
10
|
|
|
11
11
|
Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
|
|
12
|
+
A human-executed step shows AGENT as 'human' and, when it has no start marker,
|
|
13
|
+
a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
|
|
14
|
+
the verdict list, right after blockers/major/minor: manual-verify (env-blocked)
|
|
15
|
+
and human-decision (current ledger stage total).
|
|
12
16
|
`;
|
|
13
17
|
const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'];
|
|
14
18
|
// The activity-log H2 heading is language-dependent (zh template / en template).
|
|
15
19
|
const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
|
|
20
|
+
const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
|
|
16
21
|
const NEXT_H2_RE = /^##\s/;
|
|
17
22
|
// `- {time} — **{step}** by {agent} — {note}` ; the separator is an em-dash
|
|
18
23
|
// (U+2014). STEP/AGENT are non-greedy so a note that itself contains ' — ' or
|
|
@@ -23,6 +28,21 @@ const ENTRY_RE = /^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}) — \*
|
|
|
23
28
|
// without the suffix. Pairing therefore keys on the base action (including any
|
|
24
29
|
// `(Round N)`), so every round and every repeated execution pairs on its own.
|
|
25
30
|
const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
|
|
31
|
+
// Short agent tokens that actually appear in activity logs for AI executors
|
|
32
|
+
// (workflow recommended_agents claude/codex/gemini/cursor + enabled TUI opencode;
|
|
33
|
+
// note these differ from the `.airc.json` long names claude-code/gemini-cli).
|
|
34
|
+
// Any other executor token (a human name, possibly CJK) is treated as human.
|
|
35
|
+
const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
|
|
36
|
+
const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
|
|
37
|
+
const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
|
|
38
|
+
// Same match plus any leading whitespace, so folding the count into the verdict
|
|
39
|
+
// text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
|
|
40
|
+
const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
|
|
41
|
+
const REVIEW_STAGE_PREFIXES = [
|
|
42
|
+
{ prefix: 'Review Analysis', stage: 'analysis' },
|
|
43
|
+
{ prefix: 'Review Plan', stage: 'plan' },
|
|
44
|
+
{ prefix: 'Review Code', stage: 'code' }
|
|
45
|
+
];
|
|
26
46
|
function parseActivityLog(content) {
|
|
27
47
|
const lines = content.split('\n');
|
|
28
48
|
let i = 0;
|
|
@@ -82,6 +102,66 @@ function pairEntries(entries) {
|
|
|
82
102
|
}
|
|
83
103
|
return rows;
|
|
84
104
|
}
|
|
105
|
+
function parseReviewLedger(content) {
|
|
106
|
+
const lines = content.split('\n');
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]))
|
|
109
|
+
i += 1;
|
|
110
|
+
if (i >= lines.length)
|
|
111
|
+
return [];
|
|
112
|
+
const rows = [];
|
|
113
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
114
|
+
if (NEXT_H2_RE.test(lines[j]))
|
|
115
|
+
break;
|
|
116
|
+
const line = lines[j].trim();
|
|
117
|
+
if (!line.startsWith('|'))
|
|
118
|
+
continue;
|
|
119
|
+
const cells = line
|
|
120
|
+
.split('|')
|
|
121
|
+
.slice(1, -1)
|
|
122
|
+
.map((cell) => cell.trim());
|
|
123
|
+
if (cells.length < 6 || cells[0] === 'id' || /^-+$/.test(cells[0] ?? ''))
|
|
124
|
+
continue;
|
|
125
|
+
rows.push({ stage: cells[1], status: cells[4] });
|
|
126
|
+
}
|
|
127
|
+
return rows;
|
|
128
|
+
}
|
|
129
|
+
function countHumanDecisionsByStage(rows) {
|
|
130
|
+
const counts = new Map();
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
if (!isReviewStage(row.stage) || !HUMAN_DECISION_STATUSES.has(row.status))
|
|
133
|
+
continue;
|
|
134
|
+
counts.set(row.stage, (counts.get(row.stage) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
return counts;
|
|
137
|
+
}
|
|
138
|
+
function isReviewStage(stage) {
|
|
139
|
+
return stage === 'analysis' || stage === 'plan' || stage === 'code';
|
|
140
|
+
}
|
|
141
|
+
function reviewStageForStep(step) {
|
|
142
|
+
return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
|
|
143
|
+
}
|
|
144
|
+
function humanValidationCount(note) {
|
|
145
|
+
const match = ENV_BLOCKED_RE.exec(note);
|
|
146
|
+
return match ? Number(match[1]) : 0;
|
|
147
|
+
}
|
|
148
|
+
// A step is human-executed when its agent token is not a known AI token. Take
|
|
149
|
+
// the first whitespace-delimited token and drop any trailing parenthetical
|
|
150
|
+
// annotation (e.g. `张三 (executed on host)` -> `张三`) before the lookup.
|
|
151
|
+
function isHumanAgent(agent) {
|
|
152
|
+
const token = agent.trim().split(/\s+/)[0]?.replace(/\(.*$/, '') ?? '';
|
|
153
|
+
return token !== '' && !KNOWN_AI_AGENTS.has(token);
|
|
154
|
+
}
|
|
155
|
+
// Fold the two human counts into a review row's verdict NOTE: comma-joined, right
|
|
156
|
+
// after the blockers/major/minor list and before the ` → artifact` link, mirroring
|
|
157
|
+
// the review count line. The raw `(+ n env-blocked)` fragment is dropped so the
|
|
158
|
+
// env-blocked number is not shown twice (it becomes the manual-verify count).
|
|
159
|
+
function foldHumanCounts(note, decisions, envBlocked) {
|
|
160
|
+
const base = note.replace(ENV_BLOCKED_STRIP_RE, '');
|
|
161
|
+
const group = `Manual-verify: ${envBlocked}, Human-decision: ${decisions}`;
|
|
162
|
+
const arrow = base.indexOf(' → ');
|
|
163
|
+
return arrow === -1 ? `${base}, ${group}` : `${base.slice(0, arrow)}, ${group}${base.slice(arrow)}`;
|
|
164
|
+
}
|
|
85
165
|
function log(args = []) {
|
|
86
166
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
87
167
|
process.stdout.write(USAGE);
|
|
@@ -108,18 +188,21 @@ function log(args = []) {
|
|
|
108
188
|
return;
|
|
109
189
|
}
|
|
110
190
|
const steps = pairEntries(entries);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
s.step
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
s.
|
|
118
|
-
|
|
191
|
+
const humanDecisionCounts = countHumanDecisionsByStage(parseReviewLedger(content));
|
|
192
|
+
const rows = steps.map((s, idx) => {
|
|
193
|
+
const stage = reviewStageForStep(s.step);
|
|
194
|
+
const note = stage
|
|
195
|
+
? foldHumanCounts(s.note, humanDecisionCounts.get(stage) ?? 0, humanValidationCount(s.note))
|
|
196
|
+
: s.note;
|
|
197
|
+
const human = isHumanAgent(s.agent);
|
|
198
|
+
const agent = human ? 'human' : s.agent;
|
|
199
|
+
const started = s.started || (human ? '-' : '');
|
|
200
|
+
return [String(idx + 1), s.step, agent, started, s.done || (s.started ? '(in progress)' : ''), note];
|
|
201
|
+
});
|
|
119
202
|
for (const line of formatTable(TABLE_HEADERS, rows, { zebra: Boolean(process.stdout.isTTY) })) {
|
|
120
203
|
process.stdout.write(`${line}\n`);
|
|
121
204
|
}
|
|
122
205
|
process.stdout.write(`Total: ${steps.length} steps\n`);
|
|
123
206
|
}
|
|
124
|
-
export { log, parseActivityLog, pairEntries };
|
|
207
|
+
export { log, parseActivityLog, pairEntries, isHumanAgent };
|
|
125
208
|
//# sourceMappingURL=log.js.map
|
package/dist/lib/task/index.js
CHANGED
|
@@ -4,6 +4,7 @@ Commands:
|
|
|
4
4
|
cat <ref> <artifact | N> Print a task artifact (by name or number)
|
|
5
5
|
files <ref> List artifacts in a task dir (numbered)
|
|
6
6
|
grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
|
|
7
|
+
issue-body <ref> [--template <path>] Print a deterministic Issue body from task.md (描述 + 需求, or an Issue Form)
|
|
7
8
|
log <ref> Render a task's activity log as a timeline
|
|
8
9
|
ls [--all | --blocked | --completed] List tasks (default: active)
|
|
9
10
|
show <N | #N | TASK-id> Print a task.md
|
|
@@ -15,6 +16,8 @@ Examples:
|
|
|
15
16
|
ai task files 11
|
|
16
17
|
ai task grep resolveArtifact
|
|
17
18
|
ai task grep resolveArtifact 11
|
|
19
|
+
ai task issue-body 11
|
|
20
|
+
ai task issue-body 11 --template .github/ISSUE_TEMPLATE/05_other.yml
|
|
18
21
|
ai task log 11
|
|
19
22
|
ai task ls
|
|
20
23
|
ai task show 11
|
|
@@ -49,6 +52,11 @@ export async function runTask(args) {
|
|
|
49
52
|
grep(rest);
|
|
50
53
|
break;
|
|
51
54
|
}
|
|
55
|
+
case 'issue-body': {
|
|
56
|
+
const { issueBody } = await import("./commands/issue-body.js");
|
|
57
|
+
issueBody(rest);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
52
60
|
case 'log': {
|
|
53
61
|
const { log } = await import("./commands/log.js");
|
|
54
62
|
log(rest);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
// Field id -> task value mapping (single source of truth, per HD-1).
|
|
3
|
+
// Only ids that map cleanly to title / description / requirements get a value;
|
|
4
|
+
// every other text field gets `N/A`. This deliberately tightens the older
|
|
5
|
+
// "suggested" mapping table (impact / context / alternatives / steps / expected
|
|
6
|
+
// no longer flow into requirements) so the requirements checklist is never
|
|
7
|
+
// pushed into an unrelated field.
|
|
8
|
+
const TITLE_IDS = new Set(['summary', 'title']);
|
|
9
|
+
const DESCRIPTION_IDS = new Set([
|
|
10
|
+
'description',
|
|
11
|
+
'problem',
|
|
12
|
+
'what-happened',
|
|
13
|
+
'question',
|
|
14
|
+
'current-content',
|
|
15
|
+
'issue-description',
|
|
16
|
+
'detailed-description'
|
|
17
|
+
]);
|
|
18
|
+
const REQUIREMENTS_IDS = new Set(['requirements', 'solution', 'suggested-content']);
|
|
19
|
+
// Only free-text field types carry task content; structural / static fields are skipped.
|
|
20
|
+
const TEXT_FIELD_TYPES = new Set(['input', 'textarea']);
|
|
21
|
+
const PLACEHOLDER = 'N/A';
|
|
22
|
+
function mapFieldValue(id, fields) {
|
|
23
|
+
if (TITLE_IDS.has(id))
|
|
24
|
+
return fields.title;
|
|
25
|
+
if (DESCRIPTION_IDS.has(id))
|
|
26
|
+
return fields.description;
|
|
27
|
+
if (REQUIREMENTS_IDS.has(id))
|
|
28
|
+
return fields.requirements;
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render the final Issue body for a GitHub Issue Form (scenario A).
|
|
33
|
+
*
|
|
34
|
+
* Walks the form's `body[]` in order, skips `markdown` / `dropdown` /
|
|
35
|
+
* `checkboxes`, and renders each `input` / `textarea` as `### {label}` followed
|
|
36
|
+
* by the deterministically-mapped task value (or `N/A` when the field has no
|
|
37
|
+
* reliable source). The template structure is preserved; the whole task.md and
|
|
38
|
+
* its scaffolding sections are never emitted.
|
|
39
|
+
*
|
|
40
|
+
* Throws on unreadable / non-object YAML or a missing `body[]` list so the
|
|
41
|
+
* caller can fall back to the default body.
|
|
42
|
+
*/
|
|
43
|
+
function renderTemplateBody(formText, fields) {
|
|
44
|
+
const doc = parse(formText);
|
|
45
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.body)) {
|
|
46
|
+
throw new Error('Issue Form has no body[] list');
|
|
47
|
+
}
|
|
48
|
+
const sections = [];
|
|
49
|
+
for (const raw of doc.body) {
|
|
50
|
+
if (!raw || typeof raw !== 'object')
|
|
51
|
+
continue;
|
|
52
|
+
const type = typeof raw.type === 'string' ? raw.type : '';
|
|
53
|
+
if (!TEXT_FIELD_TYPES.has(type))
|
|
54
|
+
continue;
|
|
55
|
+
const label = typeof raw.attributes?.label === 'string' ? raw.attributes.label.trim() : '';
|
|
56
|
+
if (!label)
|
|
57
|
+
continue;
|
|
58
|
+
const id = typeof raw.id === 'string' ? raw.id : '';
|
|
59
|
+
const mapped = mapFieldValue(id, fields);
|
|
60
|
+
const value = mapped.trim() === '' ? PLACEHOLDER : mapped;
|
|
61
|
+
sections.push(`### ${label}\n\n${value}`);
|
|
62
|
+
}
|
|
63
|
+
return `${sections.join('\n\n')}\n`;
|
|
64
|
+
}
|
|
65
|
+
export { renderTemplateBody, mapFieldValue, PLACEHOLDER };
|
|
66
|
+
//# sourceMappingURL=issue-form.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function escapeRegExp(value) {
|
|
2
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Return the body of the first `## {alias}` section (any alias matches), from
|
|
6
|
+
* the heading line to the next `## ` heading or EOF. Lines are preserved
|
|
7
|
+
* verbatim (checkbox text is never normalized); only leading/trailing blank
|
|
8
|
+
* lines are trimmed. Returns '' when no alias heading is present.
|
|
9
|
+
*/
|
|
10
|
+
function extractSection(content, aliases) {
|
|
11
|
+
const lines = content.split('\n');
|
|
12
|
+
let start = -1;
|
|
13
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
14
|
+
const line = lines[i].trim();
|
|
15
|
+
if (aliases.some((alias) => new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`).test(line))) {
|
|
16
|
+
start = i + 1;
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (start === -1)
|
|
21
|
+
return '';
|
|
22
|
+
let end = lines.length;
|
|
23
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
24
|
+
if (/^##\s+/.test(lines[i])) {
|
|
25
|
+
end = i;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return the heading alias that actually appears as a `## {alias}` line, so a
|
|
33
|
+
* rendered section can mirror the source language. Falls back to the first
|
|
34
|
+
* alias when none is present.
|
|
35
|
+
*/
|
|
36
|
+
function findSectionHeading(content, aliases) {
|
|
37
|
+
for (const alias of aliases) {
|
|
38
|
+
if (new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`, 'm').test(content))
|
|
39
|
+
return alias;
|
|
40
|
+
}
|
|
41
|
+
return aliases[0];
|
|
42
|
+
}
|
|
43
|
+
export { extractSection, findSectionHeading };
|
|
44
|
+
//# sourceMappingURL=sections.js.map
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import * as p from '@clack/prompts';
|
|
4
2
|
import pc from 'picocolors';
|
|
5
3
|
import { loadConfig } from '../config.ts';
|
|
6
4
|
import { sandboxBranchLabel, sandboxLabel } from '../constants.ts';
|
|
7
5
|
import { detectEngine } from '../engine.ts';
|
|
8
|
-
import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
|
|
9
6
|
import { formatTable } from '../../table.ts';
|
|
10
7
|
import { lookupShortIdByBranch } from '../../task/short-id.ts';
|
|
11
8
|
import { fetchSandboxRows } from './list-running.ts';
|
|
@@ -20,7 +17,10 @@ id bound to each container's branch (via
|
|
|
20
17
|
.agents/workspace/active/.short-ids.json), or '-' if no active task is
|
|
21
18
|
bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
|
|
22
19
|
A '-' means no active task is bound to that branch, so the sandbox is free
|
|
23
|
-
to remove with "ai sandbox rm <branch>"
|
|
20
|
+
to remove with "ai sandbox rm <branch>".
|
|
21
|
+
|
|
22
|
+
Use "ai sandbox show <ref>" for a single sandbox's worktree and per-tool
|
|
23
|
+
state paths.`;
|
|
24
24
|
|
|
25
25
|
const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'] as const;
|
|
26
26
|
|
|
@@ -40,14 +40,6 @@ export function formatContainerTable(rows: ContainerTableRow[], zebra = false):
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function listChildren(dir: string): string[] {
|
|
44
|
-
if (!fs.existsSync(dir)) {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
43
|
export function ls(args: string[] = []): void {
|
|
52
44
|
if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
|
|
53
45
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -56,7 +48,6 @@ export function ls(args: string[] = []): void {
|
|
|
56
48
|
|
|
57
49
|
const config = loadConfig();
|
|
58
50
|
const engine = detectEngine(config);
|
|
59
|
-
const tools = resolveTools(config);
|
|
60
51
|
const label = sandboxLabel(config);
|
|
61
52
|
const { running, nonRunning } = fetchSandboxRows(engine, label, sandboxBranchLabel(config));
|
|
62
53
|
|
|
@@ -87,27 +78,4 @@ export function ls(args: string[] = []): void {
|
|
|
87
78
|
);
|
|
88
79
|
}
|
|
89
80
|
}
|
|
90
|
-
|
|
91
|
-
p.log.step('Worktrees');
|
|
92
|
-
const worktrees = listChildren(config.worktreeBase);
|
|
93
|
-
if (worktrees.length === 0) {
|
|
94
|
-
p.log.warn(' No sandbox worktrees');
|
|
95
|
-
} else {
|
|
96
|
-
for (const worktree of worktrees) {
|
|
97
|
-
process.stdout.write(` ${worktree}\n`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
for (const tool of tools) {
|
|
102
|
-
p.log.step(`${tool.name} state`);
|
|
103
|
-
const entries = toolProjectDirCandidates(tool, config.project)
|
|
104
|
-
.flatMap((dir) => listChildren(dir));
|
|
105
|
-
if (entries.length === 0) {
|
|
106
|
-
p.log.warn(` No ${tool.name} sandbox state`);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
for (const entry of entries) {
|
|
110
|
-
process.stdout.write(` ${entry}\n`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
81
|
}
|