@relipa/ai-flow-kit 0.1.0-beta.0 → 0.1.1-beta.0
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 +497 -497
- package/bin/aiflow.js +525 -525
- package/bin/ak.js +0 -0
- package/custom/mcp-presets/README.md +0 -0
- package/custom/mcp-presets/backlog.json +0 -0
- package/custom/mcp-presets/figma-desktop.json +0 -0
- package/custom/mcp-presets/figma.json +0 -0
- package/custom/mcp-presets/gitnexus.json +0 -0
- package/custom/mcp-presets/google-sheets.json +0 -0
- package/custom/mcp-presets/jira.json +0 -0
- package/custom/prompts/bug-fix.md +0 -0
- package/custom/prompts/feature.md +0 -0
- package/custom/prompts/investigation.md +0 -0
- package/custom/rules/code-style.md +0 -0
- package/custom/rules/java/code-style.md +0 -0
- package/custom/rules/java/naming.md +0 -0
- package/custom/rules/java/review-checklist.md +0 -0
- package/custom/rules/java/spring-boot-examples.md +0 -0
- package/custom/rules/javascript/code-style.md +0 -0
- package/custom/rules/javascript/naming.md +0 -0
- package/custom/rules/naming.md +0 -0
- package/custom/rules/php/code-style.md +0 -0
- package/custom/rules/php/naming.md +0 -0
- package/custom/rules/project-conventions.md +0 -0
- package/custom/rules/review-checklist.md +0 -0
- package/custom/skills/figma-to-component/SKILL.md +294 -207
- package/custom/skills/generate-spec/SKILL.md +0 -0
- package/custom/skills/impact-analysis/SKILL.md +0 -0
- package/custom/skills/investigate-bug/SKILL.md +0 -0
- package/custom/skills/read-study-requirement/SKILL.md +334 -334
- package/custom/skills/report-customer/SKILL.md +0 -0
- package/custom/skills/review-plan/SKILL.md +0 -0
- package/custom/templates/laravel.md +0 -0
- package/custom/templates/nestjs.md +0 -0
- package/custom/templates/nextjs.md +0 -0
- package/custom/templates/nodejs-express.md +0 -0
- package/custom/templates/php-plain.md +0 -0
- package/custom/templates/php.md +0 -0
- package/custom/templates/python-django.md +0 -0
- package/custom/templates/python-fastapi.md +0 -0
- package/custom/templates/python.md +0 -0
- package/custom/templates/reactjs.md +0 -0
- package/custom/templates/shared/gate-workflow.md +91 -91
- package/custom/templates/spring-boot.md +0 -0
- package/custom/templates/tools/claude.md +0 -0
- package/custom/templates/tools/copilot.md +0 -0
- package/custom/templates/tools/cursor.md +0 -0
- package/custom/templates/tools/gemini.md +0 -0
- package/custom/templates/tools/generic.md +0 -0
- package/custom/templates/vue-nuxt.md +0 -0
- package/docs/common/AIFLOW.md +0 -0
- package/docs/common/CHANGELOG.md +334 -334
- package/docs/common/QUICK_START.md +537 -537
- package/docs/common/ai-integration.md +0 -0
- package/docs/common/cli-reference.md +657 -657
- package/docs/common/configuration.md +0 -0
- package/docs/common/getting-started.md +0 -0
- package/docs/common/troubleshooting.md +0 -0
- package/docs/common/workflows/bug-fix.md +0 -0
- package/docs/common/workflows/feature.md +0 -0
- package/docs/common/workflows/figma.md +0 -0
- package/docs/common/workflows/impact-analysis.md +0 -0
- package/docs/common/workflows/investigation.md +0 -0
- package/docs/common/workflows/refactor.md +0 -0
- package/docs/project/ARCHITECTURE.md +0 -0
- package/index.js +0 -0
- package/package.json +68 -67
- package/scripts/checkpoint.js +0 -0
- package/scripts/config.js +0 -0
- package/scripts/context.js +0 -0
- package/scripts/create-score-excel.js +0 -0
- package/scripts/detect.js +0 -0
- package/scripts/doctor.js +26 -0
- package/scripts/gitnexus-worker.js +0 -0
- package/scripts/guide.js +314 -314
- package/scripts/hooks/block-git-write.js +0 -0
- package/scripts/hooks/session-start.js +315 -315
- package/scripts/hooks/session-stop.js +0 -0
- package/scripts/init.js +0 -0
- package/scripts/link-resolver.js +0 -0
- package/scripts/memory.js +0 -0
- package/scripts/prompt.js +95 -9
- package/scripts/remove.js +0 -0
- package/scripts/score_members.js +320 -0
- package/scripts/task.js +0 -0
- package/scripts/telemetry/cli.js +0 -0
- package/scripts/telemetry/config.js +0 -0
- package/scripts/telemetry/crypto.js +0 -0
- package/scripts/telemetry/flush.js +0 -0
- package/scripts/telemetry/record.js +0 -0
- package/scripts/update.js +0 -0
- package/scripts/use.js +1162 -1162
- package/scripts/validate.js +0 -0
- package/upstream/.claude-plugin/marketplace.json +0 -0
- package/upstream/.claude-plugin/plugin.json +0 -0
- package/upstream/.codex/INSTALL.md +0 -0
- package/upstream/.cursor-plugin/plugin.json +0 -0
- package/upstream/.gitattributes +0 -0
- package/upstream/.github/FUNDING.yml +0 -0
- package/upstream/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- package/upstream/.github/ISSUE_TEMPLATE/config.yml +0 -0
- package/upstream/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- package/upstream/.github/ISSUE_TEMPLATE/platform_support.md +0 -0
- package/upstream/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- package/upstream/.opencode/INSTALL.md +0 -0
- package/upstream/.opencode/plugins/superpowers.js +0 -0
- package/upstream/.version-bump.json +0 -0
- package/upstream/AGENTS.md +0 -0
- package/upstream/CHANGELOG.md +0 -0
- package/upstream/CLAUDE.md +0 -0
- package/upstream/CODE_OF_CONDUCT.md +0 -0
- package/upstream/GEMINI.md +0 -0
- package/upstream/LICENSE +0 -0
- package/upstream/README.md +0 -0
- package/upstream/RELEASE-NOTES.md +0 -0
- package/upstream/agents/code-reviewer.md +0 -0
- package/upstream/commands/brainstorm.md +0 -0
- package/upstream/commands/execute-plan.md +0 -0
- package/upstream/commands/write-plan.md +0 -0
- package/upstream/docs/README.codex.md +0 -0
- package/upstream/docs/README.opencode.md +0 -0
- package/upstream/docs/plans/2025-11-22-opencode-support-design.md +0 -0
- package/upstream/docs/plans/2025-11-22-opencode-support-implementation.md +0 -0
- package/upstream/docs/plans/2025-11-28-skills-improvements-from-user-feedback.md +0 -0
- package/upstream/docs/plans/2026-01-17-visual-brainstorming.md +0 -0
- package/upstream/docs/superpowers/plans/2026-01-22-document-review-system.md +0 -0
- package/upstream/docs/superpowers/plans/2026-02-19-visual-brainstorming-refactor.md +0 -0
- package/upstream/docs/superpowers/plans/2026-03-11-zero-dep-brainstorm-server.md +0 -0
- package/upstream/docs/superpowers/plans/2026-03-23-codex-app-compatibility.md +0 -0
- package/upstream/docs/superpowers/specs/2026-01-22-document-review-system-design.md +0 -0
- package/upstream/docs/superpowers/specs/2026-02-19-visual-brainstorming-refactor-design.md +0 -0
- package/upstream/docs/superpowers/specs/2026-03-11-zero-dep-brainstorm-server-design.md +0 -0
- package/upstream/docs/superpowers/specs/2026-03-23-codex-app-compatibility-design.md +0 -0
- package/upstream/docs/testing.md +0 -0
- package/upstream/docs/windows/polyglot-hooks.md +0 -0
- package/upstream/gemini-extension.json +0 -0
- package/upstream/hooks/hooks-cursor.json +0 -0
- package/upstream/hooks/hooks.json +0 -0
- package/upstream/hooks/run-hook.cmd +0 -0
- package/upstream/hooks/session-start +0 -0
- package/upstream/package.json +0 -0
- package/upstream/scripts/bump-version.sh +0 -0
- package/upstream/skills/brainstorming/SKILL.md +0 -0
- package/upstream/skills/brainstorming/scripts/frame-template.html +0 -0
- package/upstream/skills/brainstorming/scripts/helper.js +0 -0
- package/upstream/skills/brainstorming/scripts/server.cjs +0 -0
- package/upstream/skills/brainstorming/scripts/start-server.sh +0 -0
- package/upstream/skills/brainstorming/scripts/stop-server.sh +0 -0
- package/upstream/skills/brainstorming/spec-document-reviewer-prompt.md +0 -0
- package/upstream/skills/brainstorming/visual-companion.md +0 -0
- package/upstream/skills/dispatching-parallel-agents/SKILL.md +0 -0
- package/upstream/skills/executing-plans/SKILL.md +0 -0
- package/upstream/skills/finishing-a-development-branch/SKILL.md +0 -0
- package/upstream/skills/receiving-code-review/SKILL.md +0 -0
- package/upstream/skills/requesting-code-review/SKILL.md +0 -0
- package/upstream/skills/requesting-code-review/code-reviewer.md +0 -0
- package/upstream/skills/subagent-driven-development/SKILL.md +0 -0
- package/upstream/skills/subagent-driven-development/code-quality-reviewer-prompt.md +0 -0
- package/upstream/skills/subagent-driven-development/implementer-prompt.md +0 -0
- package/upstream/skills/subagent-driven-development/spec-reviewer-prompt.md +0 -0
- package/upstream/skills/systematic-debugging/CREATION-LOG.md +0 -0
- package/upstream/skills/systematic-debugging/SKILL.md +0 -0
- package/upstream/skills/systematic-debugging/condition-based-waiting-example.ts +0 -0
- package/upstream/skills/systematic-debugging/condition-based-waiting.md +0 -0
- package/upstream/skills/systematic-debugging/defense-in-depth.md +0 -0
- package/upstream/skills/systematic-debugging/find-polluter.sh +0 -0
- package/upstream/skills/systematic-debugging/root-cause-tracing.md +0 -0
- package/upstream/skills/systematic-debugging/test-academic.md +0 -0
- package/upstream/skills/systematic-debugging/test-pressure-1.md +0 -0
- package/upstream/skills/systematic-debugging/test-pressure-2.md +0 -0
- package/upstream/skills/systematic-debugging/test-pressure-3.md +0 -0
- package/upstream/skills/tdd-lean/SKILL.md +0 -0
- package/upstream/skills/test-driven-development/SKILL.md +0 -0
- package/upstream/skills/test-driven-development/testing-anti-patterns.md +0 -0
- package/upstream/skills/using-git-worktrees/SKILL.md +0 -0
- package/upstream/skills/using-superpowers/SKILL.md +0 -0
- package/upstream/skills/using-superpowers/references/codex-tools.md +0 -0
- package/upstream/skills/using-superpowers/references/copilot-tools.md +0 -0
- package/upstream/skills/using-superpowers/references/gemini-tools.md +0 -0
- package/upstream/skills/verification-before-completion/SKILL.md +0 -0
- package/upstream/skills/writing-plans/SKILL.md +0 -0
- package/upstream/skills/writing-plans/plan-document-reviewer-prompt.md +0 -0
- package/upstream/skills/writing-skills/SKILL.md +0 -0
- package/upstream/skills/writing-skills/anthropic-best-practices.md +0 -0
- package/upstream/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -0
- package/upstream/skills/writing-skills/graphviz-conventions.dot +0 -0
- package/upstream/skills/writing-skills/persuasion-principles.md +0 -0
- package/upstream/skills/writing-skills/render-graphs.js +0 -0
- package/upstream/skills/writing-skills/testing-skills-with-subagents.md +0 -0
- package/upstream/tests/brainstorm-server/package-lock.json +0 -0
- package/upstream/tests/brainstorm-server/package.json +0 -0
- package/upstream/tests/brainstorm-server/server.test.js +0 -0
- package/upstream/tests/brainstorm-server/windows-lifecycle.test.sh +0 -0
- package/upstream/tests/brainstorm-server/ws-protocol.test.js +0 -0
- package/upstream/tests/claude-code/README.md +0 -0
- package/upstream/tests/claude-code/analyze-token-usage.py +0 -0
- package/upstream/tests/claude-code/run-skill-tests.sh +0 -0
- package/upstream/tests/claude-code/test-document-review-system.sh +0 -0
- package/upstream/tests/claude-code/test-helpers.sh +0 -0
- package/upstream/tests/claude-code/test-subagent-driven-development-integration.sh +0 -0
- package/upstream/tests/claude-code/test-subagent-driven-development.sh +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/action-oriented.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/after-planning-flow.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/claude-suggested-it.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/i-know-what-sdd-means.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/mid-conversation-execute-plan.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/please-use-brainstorming.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/skip-formalities.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/subagent-driven-development-please.txt +0 -0
- package/upstream/tests/explicit-skill-requests/prompts/use-systematic-debugging.txt +0 -0
- package/upstream/tests/explicit-skill-requests/run-all.sh +0 -0
- package/upstream/tests/explicit-skill-requests/run-claude-describes-sdd.sh +0 -0
- package/upstream/tests/explicit-skill-requests/run-extended-multiturn-test.sh +0 -0
- package/upstream/tests/explicit-skill-requests/run-haiku-test.sh +0 -0
- package/upstream/tests/explicit-skill-requests/run-multiturn-test.sh +0 -0
- package/upstream/tests/explicit-skill-requests/run-test.sh +0 -0
- package/upstream/tests/opencode/run-tests.sh +0 -0
- package/upstream/tests/opencode/setup.sh +0 -0
- package/upstream/tests/opencode/test-plugin-loading.sh +0 -0
- package/upstream/tests/opencode/test-priority.sh +0 -0
- package/upstream/tests/opencode/test-tools.sh +0 -0
- package/upstream/tests/skill-triggering/prompts/dispatching-parallel-agents.txt +0 -0
- package/upstream/tests/skill-triggering/prompts/executing-plans.txt +0 -0
- package/upstream/tests/skill-triggering/prompts/requesting-code-review.txt +0 -0
- package/upstream/tests/skill-triggering/prompts/systematic-debugging.txt +0 -0
- package/upstream/tests/skill-triggering/prompts/test-driven-development.txt +0 -0
- package/upstream/tests/skill-triggering/prompts/writing-plans.txt +0 -0
- package/upstream/tests/skill-triggering/run-all.sh +0 -0
- package/upstream/tests/skill-triggering/run-test.sh +0 -0
- package/upstream/tests/subagent-driven-dev/go-fractals/design.md +0 -0
- package/upstream/tests/subagent-driven-dev/go-fractals/plan.md +0 -0
- package/upstream/tests/subagent-driven-dev/go-fractals/scaffold.sh +0 -0
- package/upstream/tests/subagent-driven-dev/run-test.sh +0 -0
- package/upstream/tests/subagent-driven-dev/svelte-todo/design.md +0 -0
- package/upstream/tests/subagent-driven-dev/svelte-todo/plan.md +0 -0
- package/upstream/tests/subagent-driven-dev/svelte-todo/scaffold.sh +0 -0
package/scripts/use.js
CHANGED
|
@@ -1,1162 +1,1162 @@
|
|
|
1
|
-
const fs = require("fs-extra");
|
|
2
|
-
const path = require("path");
|
|
3
|
-
const os = require("os");
|
|
4
|
-
const chalk = require("chalk");
|
|
5
|
-
const https = require("https");
|
|
6
|
-
const { select, input } = require("@inquirer/prompts");
|
|
7
|
-
|
|
8
|
-
const PROJECT_DIR = process.cwd();
|
|
9
|
-
const AIFLOW_DIR = path.join(PROJECT_DIR, ".aiflow");
|
|
10
|
-
const STATE_FILE = path.join(AIFLOW_DIR, "state.json");
|
|
11
|
-
const CONTEXT_DIR = path.join(PROJECT_DIR, ".aiflow", "context");
|
|
12
|
-
|
|
13
|
-
// ──────────────────────────────────────────────────────────────
|
|
14
|
-
// Entry point
|
|
15
|
-
// ──────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
const useCommand = async function use(targets, options = {}) {
|
|
18
|
-
// Normalize: accept string (legacy) or array (new multi-target)
|
|
19
|
-
const targetList = Array.isArray(targets) ? targets.filter(Boolean) : (targets ? [targets] : []);
|
|
20
|
-
const primaryTarget = targetList[0] || null;
|
|
21
|
-
const supplementaryTargets = targetList.slice(1);
|
|
22
|
-
|
|
23
|
-
if (!(await fs.pathExists(STATE_FILE))) {
|
|
24
|
-
console.log(
|
|
25
|
-
chalk.red("Project is not initialized. Run `aiflow init` first."),
|
|
26
|
-
);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ── GitNexus index notification (all AI tools see this) ──────
|
|
31
|
-
await checkGitNexusIndexStatus();
|
|
32
|
-
|
|
33
|
-
// ── Manual entry ──
|
|
34
|
-
if (options.manual) {
|
|
35
|
-
return await manualContext();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── Load from local file ──
|
|
39
|
-
if (options.file) {
|
|
40
|
-
return await loadFromFile(options.file, options);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ── No target → version switcher ──
|
|
44
|
-
if (!primaryTarget) {
|
|
45
|
-
return await switchVersion();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ── Detect target type and dispatch ──
|
|
49
|
-
if (isVersion(primaryTarget)) {
|
|
50
|
-
return await switchVersion(primaryTarget);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (isTicketId(primaryTarget)) {
|
|
54
|
-
const adapter = await resolveTicketAdapter();
|
|
55
|
-
if (adapter === "jira") await loadFromJira(primaryTarget, options);
|
|
56
|
-
else await loadFromBacklog(primaryTarget, options);
|
|
57
|
-
return await enrichWithSupplementary(supplementaryTargets);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (isBacklogUrl(primaryTarget)) {
|
|
61
|
-
const id = extractBacklogId(primaryTarget);
|
|
62
|
-
await loadFromBacklog(id, options);
|
|
63
|
-
return await enrichWithSupplementary(supplementaryTargets);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ── Fallback: try Backlog then manual ──
|
|
67
|
-
console.log(chalk.yellow(`Cannot detect format for: ${primaryTarget}`));
|
|
68
|
-
console.log(
|
|
69
|
-
chalk.gray("Supported: PROJ-33, APP-123, version number, --manual"),
|
|
70
|
-
);
|
|
71
|
-
return await manualContext();
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// ──────────────────────────────────────────────────────────────
|
|
75
|
-
// Detectors
|
|
76
|
-
// ──────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
function isVersion(s) {
|
|
79
|
-
return /^\d+\.\d+\.\d+$/.test(s);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Both Backlog and Jira use the same PROJECT-NUMBER format. */
|
|
83
|
-
function isTicketId(s) {
|
|
84
|
-
return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Determine which adapter to use for a ticket ID based on configured adapters
|
|
89
|
-
* in state.json. Falls back to backlog if both or neither are configured.
|
|
90
|
-
*/
|
|
91
|
-
async function resolveTicketAdapter() {
|
|
92
|
-
try {
|
|
93
|
-
if (await fs.pathExists(STATE_FILE)) {
|
|
94
|
-
const state = await fs.readJson(STATE_FILE);
|
|
95
|
-
const adapters = state.adapters || [];
|
|
96
|
-
if (adapters.includes("jira") && !adapters.includes("backlog")) {
|
|
97
|
-
return "jira";
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch (_) {
|
|
101
|
-
/* ignore */
|
|
102
|
-
}
|
|
103
|
-
return "backlog";
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function isBacklogUrl(s) {
|
|
107
|
-
return s.includes("backlog.com/view/") || s.includes("backlogtool.com/view/");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function extractBacklogId(url) {
|
|
111
|
-
const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
|
|
112
|
-
return match ? match[1] : null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ──────────────────────────────────────────────────────────────
|
|
116
|
-
// Version switcher (original behavior)
|
|
117
|
-
// ──────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
async function switchVersion(versionReq) {
|
|
120
|
-
const versionsDir = path.join(AIFLOW_DIR, "versions");
|
|
121
|
-
if (!(await fs.pathExists(versionsDir))) {
|
|
122
|
-
console.log(chalk.red("No versions directory found."));
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const available = await fs.readdir(versionsDir);
|
|
127
|
-
if (!available.length) {
|
|
128
|
-
console.log(chalk.red("No installed versions found."));
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
let targetVersion = versionReq;
|
|
133
|
-
if (!targetVersion) {
|
|
134
|
-
targetVersion = await select({
|
|
135
|
-
message: "Select version to use:",
|
|
136
|
-
choices: available.map((v) => ({ name: `v${v}`, value: v })),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (!available.includes(targetVersion)) {
|
|
141
|
-
console.log(chalk.red(`Version v${targetVersion} is not installed.`));
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
console.log(chalk.blue(`Switching to v${targetVersion}...`));
|
|
146
|
-
const versionDir = path.join(versionsDir, targetVersion);
|
|
147
|
-
|
|
148
|
-
const claudeDir = path.join(PROJECT_DIR, ".claude");
|
|
149
|
-
await fs.emptyDir(claudeDir);
|
|
150
|
-
await fs.copy(
|
|
151
|
-
path.join(versionDir, "skills"),
|
|
152
|
-
path.join(claudeDir, "skills"),
|
|
153
|
-
{ overwrite: true },
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const rulesDir = path.join(PROJECT_DIR, ".rules");
|
|
157
|
-
await fs.emptyDir(rulesDir);
|
|
158
|
-
await fs.copy(path.join(versionDir, "rules"), rulesDir, { overwrite: true });
|
|
159
|
-
|
|
160
|
-
const state = await fs.readJson(STATE_FILE);
|
|
161
|
-
state.current_version = targetVersion;
|
|
162
|
-
await fs.writeJson(STATE_FILE, state);
|
|
163
|
-
|
|
164
|
-
console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ──────────────────────────────────────────────────────────────
|
|
168
|
-
// Backlog context loader
|
|
169
|
-
// ──────────────────────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
async function loadFromBacklog(issueKey, options = {}) {
|
|
172
|
-
const creds = await loadCredentials();
|
|
173
|
-
const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
|
|
174
|
-
const spaceKey =
|
|
175
|
-
process.env.BACKLOG_SPACE_KEY ||
|
|
176
|
-
creds.BACKLOG_SPACE_KEY ||
|
|
177
|
-
process.env.BACKLOG_DOMAIN ||
|
|
178
|
-
creds.BACKLOG_DOMAIN;
|
|
179
|
-
|
|
180
|
-
if (!apiKey || !spaceKey) {
|
|
181
|
-
console.log(chalk.yellow("⚠ Backlog credentials not set."));
|
|
182
|
-
console.log(chalk.gray("Run: aiflow init --adapter backlog"));
|
|
183
|
-
console.log(chalk.gray("Or set environment variables:"));
|
|
184
|
-
console.log(chalk.gray(" BACKLOG_API_KEY=your-api-key"));
|
|
185
|
-
console.log(chalk.gray(" BACKLOG_SPACE_KEY=your-space (e.g. mycompany)"));
|
|
186
|
-
console.log(chalk.gray("\nFalling back to manual entry...\n"));
|
|
187
|
-
return await manualContext(issueKey);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
|
|
191
|
-
const domain = spaceKey.includes(".") ? spaceKey : `${spaceKey}.backlog.com`;
|
|
192
|
-
|
|
193
|
-
console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
// Default: description only. Comments loaded only when explicitly requested.
|
|
197
|
-
const loadComments =
|
|
198
|
-
options.coms ||
|
|
199
|
-
options.withComs ||
|
|
200
|
-
options.withComments ||
|
|
201
|
-
options["with-comments"] ||
|
|
202
|
-
options.with_comments ||
|
|
203
|
-
options.cid != null ||
|
|
204
|
-
options.commentId != null ||
|
|
205
|
-
options["comment-id"] != null ||
|
|
206
|
-
options.clast != null ||
|
|
207
|
-
options.commentsLast != null ||
|
|
208
|
-
options.cfrom != null ||
|
|
209
|
-
options.commentsFrom != null ||
|
|
210
|
-
options.cto != null ||
|
|
211
|
-
options.commentsTo != null ||
|
|
212
|
-
options["comments-to"] != null;
|
|
213
|
-
|
|
214
|
-
if (loadComments) {
|
|
215
|
-
console.log(chalk.gray(" ℹ Comments requested..."));
|
|
216
|
-
}
|
|
217
|
-
const [issue, rawComments] = await Promise.all([
|
|
218
|
-
fetchBacklogIssue(domain, apiKey, issueKey),
|
|
219
|
-
loadComments
|
|
220
|
-
? fetchBacklogComments(domain, apiKey, issueKey)
|
|
221
|
-
: Promise.resolve([]),
|
|
222
|
-
]);
|
|
223
|
-
|
|
224
|
-
const comments = filterComments(rawComments, options);
|
|
225
|
-
if (loadComments) {
|
|
226
|
-
console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
|
|
227
|
-
}
|
|
228
|
-
const context = buildContextFromBacklog(issue, comments, issueKey, domain);
|
|
229
|
-
context.mode = options.full ? "full" : "fast";
|
|
230
|
-
await saveContext(context, options.save);
|
|
231
|
-
printContextSummary(context);
|
|
232
|
-
await suggestNextStep();
|
|
233
|
-
} catch (err) {
|
|
234
|
-
console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
|
|
235
|
-
console.log(chalk.gray("Falling back to manual entry...\n"));
|
|
236
|
-
await manualContext(issueKey);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Filter comments array based on CLI options:
|
|
242
|
-
* --no-comments → empty array (already handled upstream, but safe here too)
|
|
243
|
-
* --comments-last N → last N comments
|
|
244
|
-
* --comments-from N → comments starting at index N (0-based)
|
|
245
|
-
* (default) → all comments
|
|
246
|
-
*/
|
|
247
|
-
function filterComments(comments, options = {}) {
|
|
248
|
-
if (!comments || !comments.length) return [];
|
|
249
|
-
|
|
250
|
-
let result = [...comments];
|
|
251
|
-
|
|
252
|
-
// 1. Filter by specific ID if provided
|
|
253
|
-
const targetId = options.cid || options.commentId || options["comment-id"];
|
|
254
|
-
if (targetId) {
|
|
255
|
-
result = result.filter((c) => String(c.id) === String(targetId));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// 2. Filter by starting ID (from)
|
|
259
|
-
const fromId = options.cfrom || options.commentsFrom || options["comments-from"];
|
|
260
|
-
if (fromId) {
|
|
261
|
-
result = result.filter((c) => Number(c.id) >= Number(fromId));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// 3. Filter by ending ID (to)
|
|
265
|
-
const toId = options.cto || options.commentsTo || options["comments-to"];
|
|
266
|
-
if (toId) {
|
|
267
|
-
result = result.filter((c) => Number(c.id) <= Number(toId));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// 4. Filter by last N comments (if not using specific ID/range filters)
|
|
271
|
-
const lastN = options.clast || options.commentsLast;
|
|
272
|
-
if (lastN != null && !targetId && !fromId && !toId) {
|
|
273
|
-
const n = Math.max(1, lastN);
|
|
274
|
-
return result.slice(-n);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return result;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Generic Backlog GET helper
|
|
282
|
-
*/
|
|
283
|
-
function backlogGet(url) {
|
|
284
|
-
return new Promise((resolve, reject) => {
|
|
285
|
-
https
|
|
286
|
-
.get(url, (res) => {
|
|
287
|
-
let data = "";
|
|
288
|
-
res.on("data", (chunk) => (data += chunk));
|
|
289
|
-
res.on("end", () => {
|
|
290
|
-
if (res.statusCode !== 200) {
|
|
291
|
-
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
resolve(JSON.parse(data));
|
|
296
|
-
} catch (e) {
|
|
297
|
-
reject(new Error("Invalid JSON response from Backlog"));
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
})
|
|
301
|
-
.on("error", reject);
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Fetch issue detail from Backlog REST API
|
|
307
|
-
*/
|
|
308
|
-
function fetchBacklogIssue(domain, apiKey, issueKey) {
|
|
309
|
-
return backlogGet(
|
|
310
|
-
`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`,
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Fetch ALL comments of an issue from Backlog REST API.
|
|
316
|
-
* Uses minId for pagination because 'offset' is not supported for comments.
|
|
317
|
-
*/
|
|
318
|
-
async function fetchBacklogComments(domain, apiKey, issueKey) {
|
|
319
|
-
const all = [];
|
|
320
|
-
let minId = null;
|
|
321
|
-
try {
|
|
322
|
-
while (true) {
|
|
323
|
-
let url = `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&order=asc`;
|
|
324
|
-
if (minId) {
|
|
325
|
-
url += `&minId=${minId}`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const batch = await backlogGet(url);
|
|
329
|
-
if (!batch || batch.length === 0) break;
|
|
330
|
-
|
|
331
|
-
all.push(...batch);
|
|
332
|
-
if (batch.length < 100) break;
|
|
333
|
-
|
|
334
|
-
// Use the last ID as minId for the next batch
|
|
335
|
-
minId = batch[batch.length - 1].id;
|
|
336
|
-
}
|
|
337
|
-
} catch (err) {
|
|
338
|
-
console.log(
|
|
339
|
-
chalk.yellow(` ⚠ Warning: Could not fetch comments: ${err.message}`),
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
return all;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Transform Backlog API response + comments to internal context format
|
|
347
|
-
*/
|
|
348
|
-
function buildContextFromBacklog(issue, comments, issueKey, domain) {
|
|
349
|
-
const type = detectTaskType(issue);
|
|
350
|
-
|
|
351
|
-
// Format comments into readable text for AI context
|
|
352
|
-
const formattedComments = (comments || [])
|
|
353
|
-
.map((c) => {
|
|
354
|
-
// Backlog comments can have text in 'content' or be just changelogs
|
|
355
|
-
const text = c.content || "";
|
|
356
|
-
if (!text.trim()) return null;
|
|
357
|
-
|
|
358
|
-
const author = c.createdUser?.name || "Unknown";
|
|
359
|
-
const date = c.created
|
|
360
|
-
? new Date(c.created).toLocaleDateString("vi-VN")
|
|
361
|
-
: "";
|
|
362
|
-
return `[${author} - ${date}]\n${text.trim()}`;
|
|
363
|
-
})
|
|
364
|
-
.filter(Boolean);
|
|
365
|
-
|
|
366
|
-
const allText = [
|
|
367
|
-
issue.description || "",
|
|
368
|
-
...formattedComments.map((c) => c),
|
|
369
|
-
].join("\n");
|
|
370
|
-
|
|
371
|
-
return {
|
|
372
|
-
taskId: issueKey,
|
|
373
|
-
taskType: type,
|
|
374
|
-
title: issue.summary || "",
|
|
375
|
-
description: issue.description || "",
|
|
376
|
-
status: issue.status?.name || "Unknown",
|
|
377
|
-
priority: issue.priority?.name || "Unknown",
|
|
378
|
-
assignee: issue.assignee?.name || "Unassigned",
|
|
379
|
-
|
|
380
|
-
// Comments from all participants
|
|
381
|
-
comments: formattedComments,
|
|
382
|
-
commentCount: formattedComments.length,
|
|
383
|
-
|
|
384
|
-
// Parse checklist-style acceptance criteria from description
|
|
385
|
-
acceptanceCriteria: extractCriteria(issue.description || ""),
|
|
386
|
-
|
|
387
|
-
// Related files mentioned anywhere in ticket
|
|
388
|
-
context: {
|
|
389
|
-
files: extractFiles(allText),
|
|
390
|
-
mentionedServices: [],
|
|
391
|
-
relatedTickets: extractRelatedTickets(allText),
|
|
392
|
-
},
|
|
393
|
-
|
|
394
|
-
metadata: {
|
|
395
|
-
created: issue.created,
|
|
396
|
-
updated: issue.updated,
|
|
397
|
-
loadedFrom: "backlog",
|
|
398
|
-
adapter: "backlog",
|
|
399
|
-
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
400
|
-
},
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ──────────────────────────────────────────────────────────────
|
|
405
|
-
// Jira context loader
|
|
406
|
-
// ──────────────────────────────────────────────────────────────
|
|
407
|
-
|
|
408
|
-
async function loadFromJira(issueKey, options = {}) {
|
|
409
|
-
const creds = await loadCredentials();
|
|
410
|
-
const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
|
|
411
|
-
const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
|
|
412
|
-
const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
|
|
413
|
-
|
|
414
|
-
if (!apiToken || !email || !domain) {
|
|
415
|
-
console.log(chalk.yellow("⚠ Jira credentials not set."));
|
|
416
|
-
console.log(chalk.gray("Run: aiflow init --adapter jira"));
|
|
417
|
-
console.log(
|
|
418
|
-
chalk.gray(
|
|
419
|
-
"Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN",
|
|
420
|
-
),
|
|
421
|
-
);
|
|
422
|
-
console.log(chalk.gray("\nFalling back to manual entry...\n"));
|
|
423
|
-
return await manualContext(issueKey);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
|
|
427
|
-
|
|
428
|
-
try {
|
|
429
|
-
const loadComments =
|
|
430
|
-
options.coms ||
|
|
431
|
-
options.withComs ||
|
|
432
|
-
options.withComments ||
|
|
433
|
-
options["with-comments"] ||
|
|
434
|
-
options.with_comments ||
|
|
435
|
-
options.cid != null ||
|
|
436
|
-
options.commentId != null ||
|
|
437
|
-
options["comment-id"] != null ||
|
|
438
|
-
options.clast != null ||
|
|
439
|
-
options.commentsLast != null ||
|
|
440
|
-
options.cfrom != null ||
|
|
441
|
-
options.commentsFrom != null ||
|
|
442
|
-
options.cto != null ||
|
|
443
|
-
options.commentsTo != null ||
|
|
444
|
-
options["comments-to"] != null;
|
|
445
|
-
|
|
446
|
-
if (loadComments) {
|
|
447
|
-
console.log(chalk.gray(" ℹ Comments requested..."));
|
|
448
|
-
}
|
|
449
|
-
const [issue, rawComments] = await Promise.all([
|
|
450
|
-
fetchJiraIssue(domain, email, apiToken, issueKey),
|
|
451
|
-
loadComments
|
|
452
|
-
? fetchJiraComments(domain, email, apiToken, issueKey)
|
|
453
|
-
: Promise.resolve([]),
|
|
454
|
-
]);
|
|
455
|
-
|
|
456
|
-
const comments = filterComments(rawComments, options);
|
|
457
|
-
if (loadComments) {
|
|
458
|
-
console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
|
|
459
|
-
}
|
|
460
|
-
const context = buildContextFromJira(issue, comments, issueKey, domain);
|
|
461
|
-
context.mode = options.full ? "full" : "fast";
|
|
462
|
-
await saveContext(context, options.save);
|
|
463
|
-
printContextSummary(context);
|
|
464
|
-
await suggestNextStep();
|
|
465
|
-
} catch (err) {
|
|
466
|
-
console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
|
|
467
|
-
console.log(chalk.gray("Falling back to manual entry...\n"));
|
|
468
|
-
await manualContext(issueKey);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function fetchJiraIssue(domain, email, apiToken, issueKey) {
|
|
473
|
-
return new Promise((resolve, reject) => {
|
|
474
|
-
const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
|
|
475
|
-
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
|
|
476
|
-
const options = {
|
|
477
|
-
headers: {
|
|
478
|
-
Authorization: `Basic ${auth}`,
|
|
479
|
-
Accept: "application/json",
|
|
480
|
-
},
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
https
|
|
484
|
-
.get(url, options, (res) => {
|
|
485
|
-
let data = "";
|
|
486
|
-
res.on("data", (chunk) => (data += chunk));
|
|
487
|
-
res.on("end", () => {
|
|
488
|
-
if (res.statusCode !== 200) {
|
|
489
|
-
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
try {
|
|
493
|
-
resolve(JSON.parse(data));
|
|
494
|
-
} catch (e) {
|
|
495
|
-
reject(new Error("Invalid JSON response from Jira"));
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
})
|
|
499
|
-
.on("error", reject);
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Fetch ALL comments of an issue from Jira REST API.
|
|
505
|
-
*/
|
|
506
|
-
async function fetchJiraComments(domain, email, apiToken, issueKey) {
|
|
507
|
-
return new Promise((resolve, reject) => {
|
|
508
|
-
const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
|
|
509
|
-
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}/comment`;
|
|
510
|
-
const options = {
|
|
511
|
-
headers: {
|
|
512
|
-
Authorization: `Basic ${auth}`,
|
|
513
|
-
Accept: "application/json",
|
|
514
|
-
},
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
https
|
|
518
|
-
.get(url, options, (res) => {
|
|
519
|
-
let data = "";
|
|
520
|
-
res.on("data", (chunk) => (data += chunk));
|
|
521
|
-
res.on("end", () => {
|
|
522
|
-
if (res.statusCode !== 200) {
|
|
523
|
-
// Comments are optional, but log if we failed
|
|
524
|
-
console.log(
|
|
525
|
-
chalk.yellow(
|
|
526
|
-
` ⚠ Warning: Jira comments API returned ${res.statusCode}`,
|
|
527
|
-
),
|
|
528
|
-
);
|
|
529
|
-
resolve([]);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
try {
|
|
533
|
-
const json = JSON.parse(data);
|
|
534
|
-
resolve(json.comments || []);
|
|
535
|
-
} catch (e) {
|
|
536
|
-
resolve([]);
|
|
537
|
-
}
|
|
538
|
-
});
|
|
539
|
-
})
|
|
540
|
-
.on("error", () => resolve([]));
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
function buildContextFromJira(issue, comments, issueKey, domain) {
|
|
545
|
-
const fields = issue.fields || {};
|
|
546
|
-
const type = detectTaskTypeFromString(fields.issuetype?.name || "");
|
|
547
|
-
|
|
548
|
-
// Format comments for Jira (v3 uses ADF format for body)
|
|
549
|
-
const formattedComments = (comments || [])
|
|
550
|
-
.map((c) => {
|
|
551
|
-
const author = c.author?.displayName || "Unknown";
|
|
552
|
-
const date = c.created
|
|
553
|
-
? new Date(c.created).toLocaleDateString("vi-VN")
|
|
554
|
-
: "";
|
|
555
|
-
const body =
|
|
556
|
-
c.body?.content
|
|
557
|
-
?.map(
|
|
558
|
-
(block) => block.content?.map((inner) => inner.text).join("") || "",
|
|
559
|
-
)
|
|
560
|
-
.join("\n") || "";
|
|
561
|
-
return body.trim() ? `[${author} - ${date}]\n${body.trim()}` : null;
|
|
562
|
-
})
|
|
563
|
-
.filter(Boolean);
|
|
564
|
-
|
|
565
|
-
return {
|
|
566
|
-
taskId: issueKey,
|
|
567
|
-
taskType: type,
|
|
568
|
-
title: fields.summary || "",
|
|
569
|
-
description:
|
|
570
|
-
fields.description?.content
|
|
571
|
-
?.map((block) => block.content?.map((c) => c.text).join("") || "")
|
|
572
|
-
.join("\n") || "",
|
|
573
|
-
status: fields.status?.name || "Unknown",
|
|
574
|
-
priority: fields.priority?.name || "Unknown",
|
|
575
|
-
assignee: fields.assignee?.displayName || "Unassigned",
|
|
576
|
-
|
|
577
|
-
// Comments
|
|
578
|
-
comments: formattedComments,
|
|
579
|
-
commentCount: formattedComments.length,
|
|
580
|
-
|
|
581
|
-
acceptanceCriteria: extractCriteria(fields.description?.toString() || ""),
|
|
582
|
-
context: {
|
|
583
|
-
files: [],
|
|
584
|
-
relatedTickets: [],
|
|
585
|
-
},
|
|
586
|
-
metadata: {
|
|
587
|
-
created: fields.created,
|
|
588
|
-
updated: fields.updated,
|
|
589
|
-
loadedFrom: "jira",
|
|
590
|
-
adapter: "jira",
|
|
591
|
-
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
|
|
592
|
-
},
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// ──────────────────────────────────────────────────────────────
|
|
597
|
-
// Manual context entry
|
|
598
|
-
// ──────────────────────────────────────────────────────────────
|
|
599
|
-
|
|
600
|
-
async function manualContext(prefillId = "") {
|
|
601
|
-
// Load existing context for pre-fill (Edit mode)
|
|
602
|
-
let existing = {};
|
|
603
|
-
const currentPath = path.join(CONTEXT_DIR, "current.json");
|
|
604
|
-
if (await fs.pathExists(currentPath)) {
|
|
605
|
-
existing = await fs.readJson(currentPath).catch(() => ({}));
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
const isEdit = !!existing.taskId;
|
|
609
|
-
if (isEdit) {
|
|
610
|
-
console.log(chalk.cyan("\nEdit Context\n"));
|
|
611
|
-
console.log(
|
|
612
|
-
chalk.gray("Press Enter to keep existing value shown in [brackets]\n"),
|
|
613
|
-
);
|
|
614
|
-
} else {
|
|
615
|
-
console.log(chalk.cyan("\nManual Context Entry\n"));
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Ticket ID
|
|
619
|
-
const defaultId = prefillId || existing.taskId || "";
|
|
620
|
-
const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : "";
|
|
621
|
-
const idInput = await input({
|
|
622
|
-
message: `Ticket ID (e.g. PROJ-33)${idHint}:`,
|
|
623
|
-
default: "",
|
|
624
|
-
});
|
|
625
|
-
const taskId = idInput.trim() || defaultId;
|
|
626
|
-
|
|
627
|
-
// Title
|
|
628
|
-
const defaultTitle = existing.title || "";
|
|
629
|
-
const titlePreview =
|
|
630
|
-
defaultTitle.length > 50
|
|
631
|
-
? defaultTitle.substring(0, 50) + "…"
|
|
632
|
-
: defaultTitle;
|
|
633
|
-
const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : "";
|
|
634
|
-
const titleInput = await input({
|
|
635
|
-
message: `Title${titleHint}:`,
|
|
636
|
-
default: "",
|
|
637
|
-
});
|
|
638
|
-
const title = titleInput.trim() || defaultTitle;
|
|
639
|
-
|
|
640
|
-
// Description
|
|
641
|
-
const defaultDesc = existing.description || "";
|
|
642
|
-
const descPreview =
|
|
643
|
-
defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + "…" : defaultDesc;
|
|
644
|
-
const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : "";
|
|
645
|
-
const descInput = await input({
|
|
646
|
-
message: `Description (brief)${descHint}:`,
|
|
647
|
-
default: "",
|
|
648
|
-
});
|
|
649
|
-
const description = descInput.trim() || defaultDesc;
|
|
650
|
-
|
|
651
|
-
// Task type — pre-select existing value if available
|
|
652
|
-
const taskType = await select({
|
|
653
|
-
message: "Task type:",
|
|
654
|
-
choices: [
|
|
655
|
-
{ name: "🐛 Bug Fix", value: "bug-fix" },
|
|
656
|
-
{ name: "✨ Feature", value: "feature" },
|
|
657
|
-
{ name: "🔍 Investigation", value: "investigation" },
|
|
658
|
-
{ name: "♻️ Refactor", value: "refactor" },
|
|
659
|
-
{ name: "📊 Impact Analysis", value: "impact-analysis" },
|
|
660
|
-
{ name: "📖 Documentation", value: "documentation" },
|
|
661
|
-
],
|
|
662
|
-
default: existing.taskType || undefined,
|
|
663
|
-
});
|
|
664
|
-
|
|
665
|
-
const context = {
|
|
666
|
-
taskId,
|
|
667
|
-
taskType,
|
|
668
|
-
title,
|
|
669
|
-
description,
|
|
670
|
-
status: existing.status || "In Progress",
|
|
671
|
-
mode: existing.mode || "auto",
|
|
672
|
-
acceptanceCriteria: existing.acceptanceCriteria || [],
|
|
673
|
-
context: existing.context || { files: [], relatedTickets: [] },
|
|
674
|
-
metadata: {
|
|
675
|
-
created: existing.metadata?.created || new Date().toISOString(),
|
|
676
|
-
updated: new Date().toISOString(),
|
|
677
|
-
loadedFrom: "manual",
|
|
678
|
-
adapter: "manual",
|
|
679
|
-
},
|
|
680
|
-
};
|
|
681
|
-
|
|
682
|
-
await saveContext(context);
|
|
683
|
-
printContextSummary(context);
|
|
684
|
-
await suggestNextStep();
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// ──────────────────────────────────────────────────────────────
|
|
688
|
-
// Load from JSON file
|
|
689
|
-
// ──────────────────────────────────────────────────────────────
|
|
690
|
-
|
|
691
|
-
async function loadFromFile(filePath, options = {}) {
|
|
692
|
-
if (!(await fs.pathExists(filePath))) {
|
|
693
|
-
console.log(chalk.red(`File not found: ${filePath}`));
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const raw = await fs.readFile(filePath, "utf-8");
|
|
698
|
-
let context;
|
|
699
|
-
|
|
700
|
-
// Try JSON first; fall back to plain-text auto-detection
|
|
701
|
-
try {
|
|
702
|
-
context = JSON.parse(raw);
|
|
703
|
-
} catch (_) {
|
|
704
|
-
console.log(
|
|
705
|
-
chalk.gray(
|
|
706
|
-
" File is not JSON — auto-detecting task info from content...",
|
|
707
|
-
),
|
|
708
|
-
);
|
|
709
|
-
context = buildContextFromPlainText(raw, filePath);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
context.mode = options.full ? "full" : "fast";
|
|
713
|
-
|
|
714
|
-
// Prompt for task type
|
|
715
|
-
context.taskType = await select({
|
|
716
|
-
message: "Task type:",
|
|
717
|
-
choices: [
|
|
718
|
-
{ name: "🐛 Bug Fix", value: "bug-fix" },
|
|
719
|
-
{ name: "✨ Feature", value: "feature" },
|
|
720
|
-
{ name: "🔍 Investigation", value: "investigation" },
|
|
721
|
-
{ name: "♻️ Refactor", value: "refactor" },
|
|
722
|
-
{ name: "📊 Impact Analysis", value: "impact-analysis" },
|
|
723
|
-
{ name: "📖 Documentation", value: "documentation" },
|
|
724
|
-
],
|
|
725
|
-
default: context.taskType || "feature",
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
await saveContext(context, options.save);
|
|
729
|
-
printContextSummary(context);
|
|
730
|
-
await suggestNextStep();
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
/**
|
|
734
|
-
* Build a minimal context object from arbitrary plain-text file content.
|
|
735
|
-
* Tries to detect ticket ID (e.g. PROJ-33, APP-123) and a title from the first non-blank line.
|
|
736
|
-
*/
|
|
737
|
-
function buildContextFromPlainText(text, filePath = "") {
|
|
738
|
-
// Detect ticket ID: first PROJ-123 style token anywhere in the text
|
|
739
|
-
const idMatch = text.match(/\b([A-Z][A-Z0-9_]+-\d+)\b/);
|
|
740
|
-
|
|
741
|
-
let taskId;
|
|
742
|
-
if (idMatch) {
|
|
743
|
-
taskId = idMatch[1];
|
|
744
|
-
} else {
|
|
745
|
-
const fileNameOnly = filePath
|
|
746
|
-
? path.basename(filePath, path.extname(filePath))
|
|
747
|
-
: "";
|
|
748
|
-
const cleanWords = fileNameOnly
|
|
749
|
-
.replace(/[^a-zA-Z0-9]/g, " ")
|
|
750
|
-
.split(/\s+/)
|
|
751
|
-
.filter(Boolean)
|
|
752
|
-
.slice(0, 5)
|
|
753
|
-
.join("-")
|
|
754
|
-
.toUpperCase();
|
|
755
|
-
const prefix = cleanWords ? `TASK-${cleanWords}-` : "TASK-";
|
|
756
|
-
taskId = `${prefix}${Date.now()}`;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Title: use full filename if available, otherwise first non-blank line
|
|
760
|
-
const fullFileName = filePath ? path.basename(filePath) : "";
|
|
761
|
-
const lines = text
|
|
762
|
-
.split(/\r?\n/)
|
|
763
|
-
.map((l) => l.trim())
|
|
764
|
-
.filter(Boolean);
|
|
765
|
-
const titleLine = fullFileName || lines.find((l) => l.length > 3) || taskId;
|
|
766
|
-
const title = titleLine.substring(0, 120);
|
|
767
|
-
|
|
768
|
-
// Task type detection from text
|
|
769
|
-
const taskType = detectTaskTypeFromString(text);
|
|
770
|
-
|
|
771
|
-
return {
|
|
772
|
-
taskId,
|
|
773
|
-
taskType,
|
|
774
|
-
title,
|
|
775
|
-
description: text.substring(0, 3000),
|
|
776
|
-
status: "In Progress",
|
|
777
|
-
assignee: "Unassigned",
|
|
778
|
-
acceptanceCriteria: extractCriteria(text),
|
|
779
|
-
context: {
|
|
780
|
-
files: extractFiles(text),
|
|
781
|
-
relatedTickets: extractRelatedTickets(text),
|
|
782
|
-
},
|
|
783
|
-
metadata: {
|
|
784
|
-
created: new Date().toISOString(),
|
|
785
|
-
updated: new Date().toISOString(),
|
|
786
|
-
loadedFrom: "file",
|
|
787
|
-
adapter: "file",
|
|
788
|
-
},
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// ──────────────────────────────────────────────────────────────
|
|
793
|
-
// Credentials loader — global (~/.aiflow/credentials.json) with project fallback
|
|
794
|
-
// ──────────────────────────────────────────────────────────────
|
|
795
|
-
|
|
796
|
-
async function loadCredentials() {
|
|
797
|
-
const globalCredsFile = path.join(
|
|
798
|
-
os.homedir(),
|
|
799
|
-
".aiflow",
|
|
800
|
-
"credentials.json",
|
|
801
|
-
);
|
|
802
|
-
if (await fs.pathExists(globalCredsFile)) {
|
|
803
|
-
const data = await fs.readJson(globalCredsFile).catch(() => ({}));
|
|
804
|
-
if (data.mcp && Object.keys(data.mcp).length > 0) return data.mcp;
|
|
805
|
-
}
|
|
806
|
-
// Fallback: project-level credentials (legacy support)
|
|
807
|
-
const projectCredsFile = path.join(
|
|
808
|
-
PROJECT_DIR,
|
|
809
|
-
".aiflow",
|
|
810
|
-
"credentials.json",
|
|
811
|
-
);
|
|
812
|
-
if (await fs.pathExists(projectCredsFile)) {
|
|
813
|
-
return await fs.readJson(projectCredsFile).catch(() => ({}));
|
|
814
|
-
}
|
|
815
|
-
return {};
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// ──────────────────────────────────────────────────────────────
|
|
819
|
-
// Context persistence
|
|
820
|
-
// ──────────────────────────────────────────────────────────────
|
|
821
|
-
|
|
822
|
-
async function saveContext(context, saveName) {
|
|
823
|
-
await fs.ensureDir(CONTEXT_DIR);
|
|
824
|
-
await fs.ensureDir(path.join(CONTEXT_DIR, "history"));
|
|
825
|
-
|
|
826
|
-
// Auto-pause any currently active task that differs from this one
|
|
827
|
-
const currentFile = path.join(CONTEXT_DIR, "current.json");
|
|
828
|
-
if (context.taskId && (await fs.pathExists(currentFile))) {
|
|
829
|
-
const prev = await fs.readJson(currentFile).catch(() => null);
|
|
830
|
-
if (prev && prev.taskId && prev.taskId !== context.taskId) {
|
|
831
|
-
try {
|
|
832
|
-
const { createOrActivateTaskState } = require("./task");
|
|
833
|
-
// Save previous task state as pending before switching
|
|
834
|
-
const prevTaskDir = path.join(AIFLOW_DIR, "tasks", prev.taskId);
|
|
835
|
-
const prevStatePath = path.join(prevTaskDir, "task-state.json");
|
|
836
|
-
await fs.ensureDir(prevTaskDir);
|
|
837
|
-
await fs.writeJson(path.join(prevTaskDir, "context.json"), prev, {
|
|
838
|
-
spaces: 2,
|
|
839
|
-
});
|
|
840
|
-
const prevState = (await fs.pathExists(prevStatePath))
|
|
841
|
-
? await fs.readJson(prevStatePath).catch(() => ({}))
|
|
842
|
-
: {};
|
|
843
|
-
const now = new Date().toISOString();
|
|
844
|
-
await fs.writeJson(
|
|
845
|
-
prevStatePath,
|
|
846
|
-
{
|
|
847
|
-
...prevState,
|
|
848
|
-
taskId: prev.taskId,
|
|
849
|
-
title: prev.title || "",
|
|
850
|
-
status: "pending",
|
|
851
|
-
updatedAt: now,
|
|
852
|
-
pausedAt: now,
|
|
853
|
-
currentGate: prevState.currentGate || 1,
|
|
854
|
-
gateApprovals: prevState.gateApprovals || {},
|
|
855
|
-
notes: prevState.notes || "",
|
|
856
|
-
createdAt: prevState.createdAt || now,
|
|
857
|
-
},
|
|
858
|
-
{ spaces: 2 },
|
|
859
|
-
);
|
|
860
|
-
console.log(chalk.gray(` Auto-paused previous task: ${prev.taskId}`));
|
|
861
|
-
} catch (_) {
|
|
862
|
-
/* task module not available */
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Save as current
|
|
868
|
-
await fs.writeJson(currentFile, context, { spaces: 2 });
|
|
869
|
-
|
|
870
|
-
// Save to history
|
|
871
|
-
const histFile = path.join(
|
|
872
|
-
CONTEXT_DIR,
|
|
873
|
-
"history",
|
|
874
|
-
`${context.taskId || "manual"}.json`,
|
|
875
|
-
);
|
|
876
|
-
await fs.writeJson(histFile, context, { spaces: 2 });
|
|
877
|
-
|
|
878
|
-
// Save named snapshot if requested
|
|
879
|
-
if (saveName) {
|
|
880
|
-
const namedFile = path.join(CONTEXT_DIR, "history", `${saveName}.json`);
|
|
881
|
-
await fs.writeJson(namedFile, context, { spaces: 2 });
|
|
882
|
-
console.log(chalk.gray(` Context also saved as: ${saveName}`));
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// Create / activate task-state for the new task
|
|
886
|
-
if (context.taskId) {
|
|
887
|
-
try {
|
|
888
|
-
const { createOrActivateTaskState } = require("./task");
|
|
889
|
-
await createOrActivateTaskState(context);
|
|
890
|
-
} catch (_) {
|
|
891
|
-
/* task module not available */
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// Update state
|
|
896
|
-
if (await fs.pathExists(STATE_FILE)) {
|
|
897
|
-
const state = await fs.readJson(STATE_FILE);
|
|
898
|
-
state.current_context = context.taskId;
|
|
899
|
-
state.current_context_type = context.taskType;
|
|
900
|
-
await fs.writeJson(STATE_FILE, state);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// ──────────────────────────────────────────────────────────────
|
|
905
|
-
// Helpers
|
|
906
|
-
// ──────────────────────────────────────────────────────────────
|
|
907
|
-
|
|
908
|
-
function detectTaskType(issue) {
|
|
909
|
-
const name = issue.issueType?.name || issue.type?.name || "";
|
|
910
|
-
const summary = issue.summary || "";
|
|
911
|
-
return detectTaskTypeFromString(name || summary);
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
function detectTaskTypeFromString(text) {
|
|
915
|
-
const lower = text.toLowerCase();
|
|
916
|
-
if (["bug", "defect", "バグ"].some((k) => lower.includes(k)))
|
|
917
|
-
return "bug-fix";
|
|
918
|
-
if (["task", "story", "feature", "タスク"].some((k) => lower.includes(k)))
|
|
919
|
-
return "feature";
|
|
920
|
-
if (["research", "investigation", "調査"].some((k) => lower.includes(k)))
|
|
921
|
-
return "investigation";
|
|
922
|
-
return "bug-fix"; // default for unknown
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
function extractCriteria(text) {
|
|
926
|
-
const lines = text.split("\n");
|
|
927
|
-
return lines
|
|
928
|
-
.filter((l) => l.trim().match(/^[-*•]|^\d+\./))
|
|
929
|
-
.map((l) => l.replace(/^[-*•\d.]+\s*/, "").trim())
|
|
930
|
-
.filter((l) => l.length > 0)
|
|
931
|
-
.slice(0, 10);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function extractFiles(text) {
|
|
935
|
-
const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
|
|
936
|
-
return [...new Set(matches)];
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
function extractRelatedTickets(text) {
|
|
940
|
-
const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
|
|
941
|
-
return [...new Set(matches)];
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function printContextSummary(context) {
|
|
945
|
-
console.log(chalk.green("\n✓ Context loaded\n"));
|
|
946
|
-
console.log(` ${chalk.white("Ticket:")} ${context.taskId}`);
|
|
947
|
-
console.log(` ${chalk.white("Type:")} ${context.taskType}`);
|
|
948
|
-
console.log(
|
|
949
|
-
` ${chalk.white("Title:")} ${context.title.substring(0, 70)}`,
|
|
950
|
-
);
|
|
951
|
-
console.log(` ${chalk.white("Status:")} ${context.status}`);
|
|
952
|
-
console.log(` ${chalk.white("Assignee:")} ${context.assignee}`);
|
|
953
|
-
if (context.description) {
|
|
954
|
-
console.log(
|
|
955
|
-
` ${chalk.white("Desc:")} ${context.description.substring(0, 80)}...`,
|
|
956
|
-
);
|
|
957
|
-
}
|
|
958
|
-
if (context.acceptanceCriteria?.length) {
|
|
959
|
-
console.log(
|
|
960
|
-
` ${chalk.white("Criteria:")} ${context.acceptanceCriteria.length} item(s)`,
|
|
961
|
-
);
|
|
962
|
-
}
|
|
963
|
-
if (context.commentCount > 0) {
|
|
964
|
-
console.log(
|
|
965
|
-
` ${chalk.white("Comments:")} ${context.commentCount} comment(s) loaded`,
|
|
966
|
-
);
|
|
967
|
-
}
|
|
968
|
-
console.log();
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// ── GitNexus index status notification ───────────────────────────
|
|
972
|
-
// Called at the top of use() so ALL AI tools (Claude/Gemini/Cursor/Copilot)
|
|
973
|
-
// see the notification when developer runs `aiflow use TICKET`.
|
|
974
|
-
const GITNEXUS_STALE_MS = 3 * 60 * 60 * 1000; // 3 hours — if still "running" after this, process died
|
|
975
|
-
|
|
976
|
-
async function checkGitNexusIndexStatus() {
|
|
977
|
-
const statusPath = path.join(AIFLOW_DIR, "gitnexus-status.json");
|
|
978
|
-
try {
|
|
979
|
-
if (!(await fs.pathExists(statusPath))) return;
|
|
980
|
-
const gn = await fs.readJson(statusPath);
|
|
981
|
-
if (gn.notified) return;
|
|
982
|
-
|
|
983
|
-
// Detect stale "running" — process was killed without updating status
|
|
984
|
-
if (gn.status === "running" && gn.startedAt) {
|
|
985
|
-
const age = Date.now() - new Date(gn.startedAt).getTime();
|
|
986
|
-
if (age > GITNEXUS_STALE_MS) {
|
|
987
|
-
gn.status = "error";
|
|
988
|
-
gn.error =
|
|
989
|
-
"timed out — process may have been killed or ran out of memory";
|
|
990
|
-
gn.notified = false;
|
|
991
|
-
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
if (gn.status === "done") {
|
|
996
|
-
console.log(
|
|
997
|
-
chalk.green(
|
|
998
|
-
"✓ GitNexus indexing complete — code intelligence tools are ready.",
|
|
999
|
-
),
|
|
1000
|
-
);
|
|
1001
|
-
gn.notified = true;
|
|
1002
|
-
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
1003
|
-
} else if (gn.status === "error") {
|
|
1004
|
-
const detail = gn.error
|
|
1005
|
-
? ` (${gn.error})`
|
|
1006
|
-
: gn.exitCode
|
|
1007
|
-
? ` (exit ${gn.exitCode})`
|
|
1008
|
-
: "";
|
|
1009
|
-
console.log(chalk.red(`✗ GitNexus indexing failed${detail}.`));
|
|
1010
|
-
// console.log(chalk.gray(' Retry: aiflow init --with-gitnexus'));
|
|
1011
|
-
gn.notified = true;
|
|
1012
|
-
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
1013
|
-
}
|
|
1014
|
-
// status === 'running' and not yet stale: still in progress, stay silent
|
|
1015
|
-
} catch (_) {}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
async function suggestNextStep() {
|
|
1019
|
-
let aiTools = ["claude"]; // Default
|
|
1020
|
-
try {
|
|
1021
|
-
if (await fs.pathExists(STATE_FILE)) {
|
|
1022
|
-
const state = await fs.readJson(STATE_FILE);
|
|
1023
|
-
if (state.aiTools && state.aiTools.length) {
|
|
1024
|
-
aiTools = state.aiTools;
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
} catch (_) {}
|
|
1028
|
-
|
|
1029
|
-
console.log(chalk.cyan("\nNext Steps:"));
|
|
1030
|
-
|
|
1031
|
-
const hasCLI = aiTools.includes("claude") || aiTools.includes("gemini");
|
|
1032
|
-
const hasIDE =
|
|
1033
|
-
aiTools.includes("claude") ||
|
|
1034
|
-
aiTools.includes("cursor") ||
|
|
1035
|
-
aiTools.includes("copilot");
|
|
1036
|
-
|
|
1037
|
-
if (hasCLI) {
|
|
1038
|
-
console.log(chalk.white("\n CLI:"));
|
|
1039
|
-
let step = 0;
|
|
1040
|
-
if (aiTools.includes("claude")) {
|
|
1041
|
-
console.log(
|
|
1042
|
-
` ${++step}. ${chalk.white("Claude:")} Run ${chalk.bold.green("claude start")} in terminal. ${chalk.gray("(Quickest way to start)")}`,
|
|
1043
|
-
);
|
|
1044
|
-
}
|
|
1045
|
-
if (aiTools.includes("gemini")) {
|
|
1046
|
-
console.log(
|
|
1047
|
-
` ${++step}. ${chalk.white("Gemini:")} Run ${chalk.bold.green("gemini")} then type ${chalk.bold.green('"start"')} or ${chalk.bold.green('"Gate 1"')}.`,
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (hasIDE) {
|
|
1053
|
-
console.log(chalk.white("\n IDE Extension Chat:"));
|
|
1054
|
-
console.log(
|
|
1055
|
-
` Run ${chalk.bold.green("aiflow prompt")} → prompt auto-copied to clipboard → paste into your AI extension chat.`,
|
|
1056
|
-
);
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
console.log(
|
|
1060
|
-
`\n ${chalk.yellow("Note:")} If the AI does not start automatically, type ${chalk.bold.green('"Gate 1"')} or ${chalk.bold.green('"Analyze ticket"')} to begin.`,
|
|
1061
|
-
);
|
|
1062
|
-
console.log();
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
async function loadTargetAsSupplementaryContext(target, creds, alreadyIds) {
|
|
1066
|
-
if (!target) return null;
|
|
1067
|
-
|
|
1068
|
-
// File path (not a ticket ID, not a backlog URL)
|
|
1069
|
-
if (!isTicketId(target) && !isBacklogUrl(target)) {
|
|
1070
|
-
try {
|
|
1071
|
-
const raw = await fs.readFile(target, 'utf-8');
|
|
1072
|
-
return {
|
|
1073
|
-
sourceType: 'file',
|
|
1074
|
-
sourcePath: target,
|
|
1075
|
-
title: path.basename(target),
|
|
1076
|
-
content: raw.substring(0, 3000),
|
|
1077
|
-
};
|
|
1078
|
-
} catch (err) {
|
|
1079
|
-
console.log(chalk.yellow(` ⚠ Could not read file: ${target}`));
|
|
1080
|
-
return null;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
const issueKey = isBacklogUrl(target) ? extractBacklogId(target) : target;
|
|
1085
|
-
if (!issueKey || alreadyIds.includes(issueKey)) return null;
|
|
1086
|
-
|
|
1087
|
-
const adapter = await resolveTicketAdapter();
|
|
1088
|
-
|
|
1089
|
-
try {
|
|
1090
|
-
if (adapter === 'jira') {
|
|
1091
|
-
const token = creds.JIRA_API_TOKEN;
|
|
1092
|
-
const email = creds.JIRA_EMAIL;
|
|
1093
|
-
const domain = creds.JIRA_DOMAIN;
|
|
1094
|
-
if (!token || !email || !domain) return null;
|
|
1095
|
-
const issue = await fetchJiraIssue(domain, email, token, issueKey);
|
|
1096
|
-
const fields = issue.fields || {};
|
|
1097
|
-
const desc = (fields.description?.content || [])
|
|
1098
|
-
.map(b => (b.content || []).map(c => c.text || '').join('')).join('\n');
|
|
1099
|
-
return {
|
|
1100
|
-
sourceType: 'ticket',
|
|
1101
|
-
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
|
|
1102
|
-
ticketId: issueKey,
|
|
1103
|
-
title: fields.summary || '',
|
|
1104
|
-
description: desc.substring(0, 2000),
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
const apiKey = creds.BACKLOG_API_KEY;
|
|
1108
|
-
const spaceKey = creds.BACKLOG_SPACE_KEY || creds.BACKLOG_DOMAIN;
|
|
1109
|
-
if (!apiKey || !spaceKey) return null;
|
|
1110
|
-
const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
|
|
1111
|
-
const issue = await fetchBacklogIssue(domain, apiKey, issueKey);
|
|
1112
|
-
return {
|
|
1113
|
-
sourceType: 'ticket',
|
|
1114
|
-
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
1115
|
-
ticketId: issueKey,
|
|
1116
|
-
title: issue.summary || '',
|
|
1117
|
-
description: (issue.description || '').substring(0, 2000),
|
|
1118
|
-
};
|
|
1119
|
-
} catch (err) {
|
|
1120
|
-
console.log(chalk.yellow(` ⚠ Could not load supplementary ${issueKey}: ${err.message}`));
|
|
1121
|
-
return null;
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
async function enrichWithSupplementary(supplementaryTargets) {
|
|
1126
|
-
const currentFile = path.join(CONTEXT_DIR, 'current.json');
|
|
1127
|
-
if (!(await fs.pathExists(currentFile))) return;
|
|
1128
|
-
|
|
1129
|
-
const ctx = await fs.readJson(currentFile).catch(() => null);
|
|
1130
|
-
if (!ctx) return;
|
|
1131
|
-
|
|
1132
|
-
const creds = await loadCredentials();
|
|
1133
|
-
const supplementary = [];
|
|
1134
|
-
const alreadyIds = [ctx.taskId].filter(Boolean);
|
|
1135
|
-
|
|
1136
|
-
for (const t of supplementaryTargets) {
|
|
1137
|
-
const sc = await loadTargetAsSupplementaryContext(t, creds, alreadyIds);
|
|
1138
|
-
if (sc) {
|
|
1139
|
-
supplementary.push(sc);
|
|
1140
|
-
if (sc.ticketId) alreadyIds.push(sc.ticketId);
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
if (ctx.description) {
|
|
1145
|
-
const { resolveLinks } = require('./link-resolver');
|
|
1146
|
-
const linked = await resolveLinks(ctx.description, creds, alreadyIds);
|
|
1147
|
-
supplementary.push(...linked);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
if (supplementary.length > 0) {
|
|
1151
|
-
ctx.supplementaryContext = supplementary;
|
|
1152
|
-
await fs.writeJson(currentFile, ctx, { spaces: 2 });
|
|
1153
|
-
const histFile = path.join(CONTEXT_DIR, 'history', `${ctx.taskId || 'manual'}.json`);
|
|
1154
|
-
if (await fs.pathExists(path.dirname(histFile))) {
|
|
1155
|
-
await fs.writeJson(histFile, ctx, { spaces: 2 });
|
|
1156
|
-
}
|
|
1157
|
-
console.log(chalk.green(` ✓ ${supplementary.length} supplementary context(s) linked`));
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
module.exports = useCommand;
|
|
1162
|
-
module.exports.loadCredentials = loadCredentials;
|
|
1
|
+
const fs = require("fs-extra");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const chalk = require("chalk");
|
|
5
|
+
const https = require("https");
|
|
6
|
+
const { select, input } = require("@inquirer/prompts");
|
|
7
|
+
|
|
8
|
+
const PROJECT_DIR = process.cwd();
|
|
9
|
+
const AIFLOW_DIR = path.join(PROJECT_DIR, ".aiflow");
|
|
10
|
+
const STATE_FILE = path.join(AIFLOW_DIR, "state.json");
|
|
11
|
+
const CONTEXT_DIR = path.join(PROJECT_DIR, ".aiflow", "context");
|
|
12
|
+
|
|
13
|
+
// ──────────────────────────────────────────────────────────────
|
|
14
|
+
// Entry point
|
|
15
|
+
// ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const useCommand = async function use(targets, options = {}) {
|
|
18
|
+
// Normalize: accept string (legacy) or array (new multi-target)
|
|
19
|
+
const targetList = Array.isArray(targets) ? targets.filter(Boolean) : (targets ? [targets] : []);
|
|
20
|
+
const primaryTarget = targetList[0] || null;
|
|
21
|
+
const supplementaryTargets = targetList.slice(1);
|
|
22
|
+
|
|
23
|
+
if (!(await fs.pathExists(STATE_FILE))) {
|
|
24
|
+
console.log(
|
|
25
|
+
chalk.red("Project is not initialized. Run `aiflow init` first."),
|
|
26
|
+
);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── GitNexus index notification (all AI tools see this) ──────
|
|
31
|
+
await checkGitNexusIndexStatus();
|
|
32
|
+
|
|
33
|
+
// ── Manual entry ──
|
|
34
|
+
if (options.manual) {
|
|
35
|
+
return await manualContext();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Load from local file ──
|
|
39
|
+
if (options.file) {
|
|
40
|
+
return await loadFromFile(options.file, options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── No target → version switcher ──
|
|
44
|
+
if (!primaryTarget) {
|
|
45
|
+
return await switchVersion();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Detect target type and dispatch ──
|
|
49
|
+
if (isVersion(primaryTarget)) {
|
|
50
|
+
return await switchVersion(primaryTarget);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (isTicketId(primaryTarget)) {
|
|
54
|
+
const adapter = await resolveTicketAdapter();
|
|
55
|
+
if (adapter === "jira") await loadFromJira(primaryTarget, options);
|
|
56
|
+
else await loadFromBacklog(primaryTarget, options);
|
|
57
|
+
return await enrichWithSupplementary(supplementaryTargets);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isBacklogUrl(primaryTarget)) {
|
|
61
|
+
const id = extractBacklogId(primaryTarget);
|
|
62
|
+
await loadFromBacklog(id, options);
|
|
63
|
+
return await enrichWithSupplementary(supplementaryTargets);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Fallback: try Backlog then manual ──
|
|
67
|
+
console.log(chalk.yellow(`Cannot detect format for: ${primaryTarget}`));
|
|
68
|
+
console.log(
|
|
69
|
+
chalk.gray("Supported: PROJ-33, APP-123, version number, --manual"),
|
|
70
|
+
);
|
|
71
|
+
return await manualContext();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ──────────────────────────────────────────────────────────────
|
|
75
|
+
// Detectors
|
|
76
|
+
// ──────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function isVersion(s) {
|
|
79
|
+
return /^\d+\.\d+\.\d+$/.test(s);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Both Backlog and Jira use the same PROJECT-NUMBER format. */
|
|
83
|
+
function isTicketId(s) {
|
|
84
|
+
return /^[A-Z][A-Z0-9_]+-\d+$/.test(s);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Determine which adapter to use for a ticket ID based on configured adapters
|
|
89
|
+
* in state.json. Falls back to backlog if both or neither are configured.
|
|
90
|
+
*/
|
|
91
|
+
async function resolveTicketAdapter() {
|
|
92
|
+
try {
|
|
93
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
94
|
+
const state = await fs.readJson(STATE_FILE);
|
|
95
|
+
const adapters = state.adapters || [];
|
|
96
|
+
if (adapters.includes("jira") && !adapters.includes("backlog")) {
|
|
97
|
+
return "jira";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (_) {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
return "backlog";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isBacklogUrl(s) {
|
|
107
|
+
return s.includes("backlog.com/view/") || s.includes("backlogtool.com/view/");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractBacklogId(url) {
|
|
111
|
+
const match = url.match(/\/view\/([A-Z][A-Z0-9_]+-\d+)/);
|
|
112
|
+
return match ? match[1] : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ──────────────────────────────────────────────────────────────
|
|
116
|
+
// Version switcher (original behavior)
|
|
117
|
+
// ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async function switchVersion(versionReq) {
|
|
120
|
+
const versionsDir = path.join(AIFLOW_DIR, "versions");
|
|
121
|
+
if (!(await fs.pathExists(versionsDir))) {
|
|
122
|
+
console.log(chalk.red("No versions directory found."));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const available = await fs.readdir(versionsDir);
|
|
127
|
+
if (!available.length) {
|
|
128
|
+
console.log(chalk.red("No installed versions found."));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let targetVersion = versionReq;
|
|
133
|
+
if (!targetVersion) {
|
|
134
|
+
targetVersion = await select({
|
|
135
|
+
message: "Select version to use:",
|
|
136
|
+
choices: available.map((v) => ({ name: `v${v}`, value: v })),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!available.includes(targetVersion)) {
|
|
141
|
+
console.log(chalk.red(`Version v${targetVersion} is not installed.`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(chalk.blue(`Switching to v${targetVersion}...`));
|
|
146
|
+
const versionDir = path.join(versionsDir, targetVersion);
|
|
147
|
+
|
|
148
|
+
const claudeDir = path.join(PROJECT_DIR, ".claude");
|
|
149
|
+
await fs.emptyDir(claudeDir);
|
|
150
|
+
await fs.copy(
|
|
151
|
+
path.join(versionDir, "skills"),
|
|
152
|
+
path.join(claudeDir, "skills"),
|
|
153
|
+
{ overwrite: true },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const rulesDir = path.join(PROJECT_DIR, ".rules");
|
|
157
|
+
await fs.emptyDir(rulesDir);
|
|
158
|
+
await fs.copy(path.join(versionDir, "rules"), rulesDir, { overwrite: true });
|
|
159
|
+
|
|
160
|
+
const state = await fs.readJson(STATE_FILE);
|
|
161
|
+
state.current_version = targetVersion;
|
|
162
|
+
await fs.writeJson(STATE_FILE, state);
|
|
163
|
+
|
|
164
|
+
console.log(chalk.green(`✓ Switched to v${targetVersion}.`));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ──────────────────────────────────────────────────────────────
|
|
168
|
+
// Backlog context loader
|
|
169
|
+
// ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async function loadFromBacklog(issueKey, options = {}) {
|
|
172
|
+
const creds = await loadCredentials();
|
|
173
|
+
const apiKey = process.env.BACKLOG_API_KEY || creds.BACKLOG_API_KEY;
|
|
174
|
+
const spaceKey =
|
|
175
|
+
process.env.BACKLOG_SPACE_KEY ||
|
|
176
|
+
creds.BACKLOG_SPACE_KEY ||
|
|
177
|
+
process.env.BACKLOG_DOMAIN ||
|
|
178
|
+
creds.BACKLOG_DOMAIN;
|
|
179
|
+
|
|
180
|
+
if (!apiKey || !spaceKey) {
|
|
181
|
+
console.log(chalk.yellow("⚠ Backlog credentials not set."));
|
|
182
|
+
console.log(chalk.gray("Run: aiflow init --adapter backlog"));
|
|
183
|
+
console.log(chalk.gray("Or set environment variables:"));
|
|
184
|
+
console.log(chalk.gray(" BACKLOG_API_KEY=your-api-key"));
|
|
185
|
+
console.log(chalk.gray(" BACKLOG_SPACE_KEY=your-space (e.g. mycompany)"));
|
|
186
|
+
console.log(chalk.gray("\nFalling back to manual entry...\n"));
|
|
187
|
+
return await manualContext(issueKey);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// spaceKey can be full domain (mycompany.backlog.com) or just the space (mycompany)
|
|
191
|
+
const domain = spaceKey.includes(".") ? spaceKey : `${spaceKey}.backlog.com`;
|
|
192
|
+
|
|
193
|
+
console.log(chalk.blue(`Fetching context from Backlog: ${issueKey}...`));
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Default: description only. Comments loaded only when explicitly requested.
|
|
197
|
+
const loadComments =
|
|
198
|
+
options.coms ||
|
|
199
|
+
options.withComs ||
|
|
200
|
+
options.withComments ||
|
|
201
|
+
options["with-comments"] ||
|
|
202
|
+
options.with_comments ||
|
|
203
|
+
options.cid != null ||
|
|
204
|
+
options.commentId != null ||
|
|
205
|
+
options["comment-id"] != null ||
|
|
206
|
+
options.clast != null ||
|
|
207
|
+
options.commentsLast != null ||
|
|
208
|
+
options.cfrom != null ||
|
|
209
|
+
options.commentsFrom != null ||
|
|
210
|
+
options.cto != null ||
|
|
211
|
+
options.commentsTo != null ||
|
|
212
|
+
options["comments-to"] != null;
|
|
213
|
+
|
|
214
|
+
if (loadComments) {
|
|
215
|
+
console.log(chalk.gray(" ℹ Comments requested..."));
|
|
216
|
+
}
|
|
217
|
+
const [issue, rawComments] = await Promise.all([
|
|
218
|
+
fetchBacklogIssue(domain, apiKey, issueKey),
|
|
219
|
+
loadComments
|
|
220
|
+
? fetchBacklogComments(domain, apiKey, issueKey)
|
|
221
|
+
: Promise.resolve([]),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
const comments = filterComments(rawComments, options);
|
|
225
|
+
if (loadComments) {
|
|
226
|
+
console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
|
|
227
|
+
}
|
|
228
|
+
const context = buildContextFromBacklog(issue, comments, issueKey, domain);
|
|
229
|
+
context.mode = options.full ? "full" : "fast";
|
|
230
|
+
await saveContext(context, options.save);
|
|
231
|
+
printContextSummary(context);
|
|
232
|
+
await suggestNextStep();
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.log(chalk.yellow(`⚠ Could not fetch from Backlog: ${err.message}`));
|
|
235
|
+
console.log(chalk.gray("Falling back to manual entry...\n"));
|
|
236
|
+
await manualContext(issueKey);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Filter comments array based on CLI options:
|
|
242
|
+
* --no-comments → empty array (already handled upstream, but safe here too)
|
|
243
|
+
* --comments-last N → last N comments
|
|
244
|
+
* --comments-from N → comments starting at index N (0-based)
|
|
245
|
+
* (default) → all comments
|
|
246
|
+
*/
|
|
247
|
+
function filterComments(comments, options = {}) {
|
|
248
|
+
if (!comments || !comments.length) return [];
|
|
249
|
+
|
|
250
|
+
let result = [...comments];
|
|
251
|
+
|
|
252
|
+
// 1. Filter by specific ID if provided
|
|
253
|
+
const targetId = options.cid || options.commentId || options["comment-id"];
|
|
254
|
+
if (targetId) {
|
|
255
|
+
result = result.filter((c) => String(c.id) === String(targetId));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. Filter by starting ID (from)
|
|
259
|
+
const fromId = options.cfrom || options.commentsFrom || options["comments-from"];
|
|
260
|
+
if (fromId) {
|
|
261
|
+
result = result.filter((c) => Number(c.id) >= Number(fromId));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 3. Filter by ending ID (to)
|
|
265
|
+
const toId = options.cto || options.commentsTo || options["comments-to"];
|
|
266
|
+
if (toId) {
|
|
267
|
+
result = result.filter((c) => Number(c.id) <= Number(toId));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 4. Filter by last N comments (if not using specific ID/range filters)
|
|
271
|
+
const lastN = options.clast || options.commentsLast;
|
|
272
|
+
if (lastN != null && !targetId && !fromId && !toId) {
|
|
273
|
+
const n = Math.max(1, lastN);
|
|
274
|
+
return result.slice(-n);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generic Backlog GET helper
|
|
282
|
+
*/
|
|
283
|
+
function backlogGet(url) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
https
|
|
286
|
+
.get(url, (res) => {
|
|
287
|
+
let data = "";
|
|
288
|
+
res.on("data", (chunk) => (data += chunk));
|
|
289
|
+
res.on("end", () => {
|
|
290
|
+
if (res.statusCode !== 200) {
|
|
291
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
resolve(JSON.parse(data));
|
|
296
|
+
} catch (e) {
|
|
297
|
+
reject(new Error("Invalid JSON response from Backlog"));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
})
|
|
301
|
+
.on("error", reject);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Fetch issue detail from Backlog REST API
|
|
307
|
+
*/
|
|
308
|
+
function fetchBacklogIssue(domain, apiKey, issueKey) {
|
|
309
|
+
return backlogGet(
|
|
310
|
+
`https://${domain}/api/v2/issues/${issueKey}?apiKey=${apiKey}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fetch ALL comments of an issue from Backlog REST API.
|
|
316
|
+
* Uses minId for pagination because 'offset' is not supported for comments.
|
|
317
|
+
*/
|
|
318
|
+
async function fetchBacklogComments(domain, apiKey, issueKey) {
|
|
319
|
+
const all = [];
|
|
320
|
+
let minId = null;
|
|
321
|
+
try {
|
|
322
|
+
while (true) {
|
|
323
|
+
let url = `https://${domain}/api/v2/issues/${issueKey}/comments?apiKey=${apiKey}&count=100&order=asc`;
|
|
324
|
+
if (minId) {
|
|
325
|
+
url += `&minId=${minId}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const batch = await backlogGet(url);
|
|
329
|
+
if (!batch || batch.length === 0) break;
|
|
330
|
+
|
|
331
|
+
all.push(...batch);
|
|
332
|
+
if (batch.length < 100) break;
|
|
333
|
+
|
|
334
|
+
// Use the last ID as minId for the next batch
|
|
335
|
+
minId = batch[batch.length - 1].id;
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.log(
|
|
339
|
+
chalk.yellow(` ⚠ Warning: Could not fetch comments: ${err.message}`),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return all;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Transform Backlog API response + comments to internal context format
|
|
347
|
+
*/
|
|
348
|
+
function buildContextFromBacklog(issue, comments, issueKey, domain) {
|
|
349
|
+
const type = detectTaskType(issue);
|
|
350
|
+
|
|
351
|
+
// Format comments into readable text for AI context
|
|
352
|
+
const formattedComments = (comments || [])
|
|
353
|
+
.map((c) => {
|
|
354
|
+
// Backlog comments can have text in 'content' or be just changelogs
|
|
355
|
+
const text = c.content || "";
|
|
356
|
+
if (!text.trim()) return null;
|
|
357
|
+
|
|
358
|
+
const author = c.createdUser?.name || "Unknown";
|
|
359
|
+
const date = c.created
|
|
360
|
+
? new Date(c.created).toLocaleDateString("vi-VN")
|
|
361
|
+
: "";
|
|
362
|
+
return `[${author} - ${date}]\n${text.trim()}`;
|
|
363
|
+
})
|
|
364
|
+
.filter(Boolean);
|
|
365
|
+
|
|
366
|
+
const allText = [
|
|
367
|
+
issue.description || "",
|
|
368
|
+
...formattedComments.map((c) => c),
|
|
369
|
+
].join("\n");
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
taskId: issueKey,
|
|
373
|
+
taskType: type,
|
|
374
|
+
title: issue.summary || "",
|
|
375
|
+
description: issue.description || "",
|
|
376
|
+
status: issue.status?.name || "Unknown",
|
|
377
|
+
priority: issue.priority?.name || "Unknown",
|
|
378
|
+
assignee: issue.assignee?.name || "Unassigned",
|
|
379
|
+
|
|
380
|
+
// Comments from all participants
|
|
381
|
+
comments: formattedComments,
|
|
382
|
+
commentCount: formattedComments.length,
|
|
383
|
+
|
|
384
|
+
// Parse checklist-style acceptance criteria from description
|
|
385
|
+
acceptanceCriteria: extractCriteria(issue.description || ""),
|
|
386
|
+
|
|
387
|
+
// Related files mentioned anywhere in ticket
|
|
388
|
+
context: {
|
|
389
|
+
files: extractFiles(allText),
|
|
390
|
+
mentionedServices: [],
|
|
391
|
+
relatedTickets: extractRelatedTickets(allText),
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
metadata: {
|
|
395
|
+
created: issue.created,
|
|
396
|
+
updated: issue.updated,
|
|
397
|
+
loadedFrom: "backlog",
|
|
398
|
+
adapter: "backlog",
|
|
399
|
+
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ──────────────────────────────────────────────────────────────
|
|
405
|
+
// Jira context loader
|
|
406
|
+
// ──────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
async function loadFromJira(issueKey, options = {}) {
|
|
409
|
+
const creds = await loadCredentials();
|
|
410
|
+
const apiToken = process.env.JIRA_API_TOKEN || creds.JIRA_API_TOKEN;
|
|
411
|
+
const email = process.env.JIRA_EMAIL || creds.JIRA_EMAIL;
|
|
412
|
+
const domain = process.env.JIRA_DOMAIN || creds.JIRA_DOMAIN;
|
|
413
|
+
|
|
414
|
+
if (!apiToken || !email || !domain) {
|
|
415
|
+
console.log(chalk.yellow("⚠ Jira credentials not set."));
|
|
416
|
+
console.log(chalk.gray("Run: aiflow init --adapter jira"));
|
|
417
|
+
console.log(
|
|
418
|
+
chalk.gray(
|
|
419
|
+
"Or set environment variables: JIRA_API_TOKEN, JIRA_EMAIL, JIRA_DOMAIN",
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
console.log(chalk.gray("\nFalling back to manual entry...\n"));
|
|
423
|
+
return await manualContext(issueKey);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log(chalk.blue(`Fetching context from Jira: ${issueKey}...`));
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const loadComments =
|
|
430
|
+
options.coms ||
|
|
431
|
+
options.withComs ||
|
|
432
|
+
options.withComments ||
|
|
433
|
+
options["with-comments"] ||
|
|
434
|
+
options.with_comments ||
|
|
435
|
+
options.cid != null ||
|
|
436
|
+
options.commentId != null ||
|
|
437
|
+
options["comment-id"] != null ||
|
|
438
|
+
options.clast != null ||
|
|
439
|
+
options.commentsLast != null ||
|
|
440
|
+
options.cfrom != null ||
|
|
441
|
+
options.commentsFrom != null ||
|
|
442
|
+
options.cto != null ||
|
|
443
|
+
options.commentsTo != null ||
|
|
444
|
+
options["comments-to"] != null;
|
|
445
|
+
|
|
446
|
+
if (loadComments) {
|
|
447
|
+
console.log(chalk.gray(" ℹ Comments requested..."));
|
|
448
|
+
}
|
|
449
|
+
const [issue, rawComments] = await Promise.all([
|
|
450
|
+
fetchJiraIssue(domain, email, apiToken, issueKey),
|
|
451
|
+
loadComments
|
|
452
|
+
? fetchJiraComments(domain, email, apiToken, issueKey)
|
|
453
|
+
: Promise.resolve([]),
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
const comments = filterComments(rawComments, options);
|
|
457
|
+
if (loadComments) {
|
|
458
|
+
console.log(chalk.gray(` ℹ Comments: ${rawComments.length} fetched, ${comments.length} kept after filtering.`));
|
|
459
|
+
}
|
|
460
|
+
const context = buildContextFromJira(issue, comments, issueKey, domain);
|
|
461
|
+
context.mode = options.full ? "full" : "fast";
|
|
462
|
+
await saveContext(context, options.save);
|
|
463
|
+
printContextSummary(context);
|
|
464
|
+
await suggestNextStep();
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.log(chalk.yellow(`⚠ Could not fetch from Jira: ${err.message}`));
|
|
467
|
+
console.log(chalk.gray("Falling back to manual entry...\n"));
|
|
468
|
+
await manualContext(issueKey);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function fetchJiraIssue(domain, email, apiToken, issueKey) {
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
|
|
475
|
+
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}`;
|
|
476
|
+
const options = {
|
|
477
|
+
headers: {
|
|
478
|
+
Authorization: `Basic ${auth}`,
|
|
479
|
+
Accept: "application/json",
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
https
|
|
484
|
+
.get(url, options, (res) => {
|
|
485
|
+
let data = "";
|
|
486
|
+
res.on("data", (chunk) => (data += chunk));
|
|
487
|
+
res.on("end", () => {
|
|
488
|
+
if (res.statusCode !== 200) {
|
|
489
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
resolve(JSON.parse(data));
|
|
494
|
+
} catch (e) {
|
|
495
|
+
reject(new Error("Invalid JSON response from Jira"));
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
})
|
|
499
|
+
.on("error", reject);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Fetch ALL comments of an issue from Jira REST API.
|
|
505
|
+
*/
|
|
506
|
+
async function fetchJiraComments(domain, email, apiToken, issueKey) {
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
const auth = Buffer.from(`${email}:${apiToken}`).toString("base64");
|
|
509
|
+
const url = `https://${domain}.atlassian.net/rest/api/3/issue/${issueKey}/comment`;
|
|
510
|
+
const options = {
|
|
511
|
+
headers: {
|
|
512
|
+
Authorization: `Basic ${auth}`,
|
|
513
|
+
Accept: "application/json",
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
https
|
|
518
|
+
.get(url, options, (res) => {
|
|
519
|
+
let data = "";
|
|
520
|
+
res.on("data", (chunk) => (data += chunk));
|
|
521
|
+
res.on("end", () => {
|
|
522
|
+
if (res.statusCode !== 200) {
|
|
523
|
+
// Comments are optional, but log if we failed
|
|
524
|
+
console.log(
|
|
525
|
+
chalk.yellow(
|
|
526
|
+
` ⚠ Warning: Jira comments API returned ${res.statusCode}`,
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
resolve([]);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const json = JSON.parse(data);
|
|
534
|
+
resolve(json.comments || []);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
resolve([]);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
})
|
|
540
|
+
.on("error", () => resolve([]));
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function buildContextFromJira(issue, comments, issueKey, domain) {
|
|
545
|
+
const fields = issue.fields || {};
|
|
546
|
+
const type = detectTaskTypeFromString(fields.issuetype?.name || "");
|
|
547
|
+
|
|
548
|
+
// Format comments for Jira (v3 uses ADF format for body)
|
|
549
|
+
const formattedComments = (comments || [])
|
|
550
|
+
.map((c) => {
|
|
551
|
+
const author = c.author?.displayName || "Unknown";
|
|
552
|
+
const date = c.created
|
|
553
|
+
? new Date(c.created).toLocaleDateString("vi-VN")
|
|
554
|
+
: "";
|
|
555
|
+
const body =
|
|
556
|
+
c.body?.content
|
|
557
|
+
?.map(
|
|
558
|
+
(block) => block.content?.map((inner) => inner.text).join("") || "",
|
|
559
|
+
)
|
|
560
|
+
.join("\n") || "";
|
|
561
|
+
return body.trim() ? `[${author} - ${date}]\n${body.trim()}` : null;
|
|
562
|
+
})
|
|
563
|
+
.filter(Boolean);
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
taskId: issueKey,
|
|
567
|
+
taskType: type,
|
|
568
|
+
title: fields.summary || "",
|
|
569
|
+
description:
|
|
570
|
+
fields.description?.content
|
|
571
|
+
?.map((block) => block.content?.map((c) => c.text).join("") || "")
|
|
572
|
+
.join("\n") || "",
|
|
573
|
+
status: fields.status?.name || "Unknown",
|
|
574
|
+
priority: fields.priority?.name || "Unknown",
|
|
575
|
+
assignee: fields.assignee?.displayName || "Unassigned",
|
|
576
|
+
|
|
577
|
+
// Comments
|
|
578
|
+
comments: formattedComments,
|
|
579
|
+
commentCount: formattedComments.length,
|
|
580
|
+
|
|
581
|
+
acceptanceCriteria: extractCriteria(fields.description?.toString() || ""),
|
|
582
|
+
context: {
|
|
583
|
+
files: [],
|
|
584
|
+
relatedTickets: [],
|
|
585
|
+
},
|
|
586
|
+
metadata: {
|
|
587
|
+
created: fields.created,
|
|
588
|
+
updated: fields.updated,
|
|
589
|
+
loadedFrom: "jira",
|
|
590
|
+
adapter: "jira",
|
|
591
|
+
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ──────────────────────────────────────────────────────────────
|
|
597
|
+
// Manual context entry
|
|
598
|
+
// ──────────────────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
async function manualContext(prefillId = "") {
|
|
601
|
+
// Load existing context for pre-fill (Edit mode)
|
|
602
|
+
let existing = {};
|
|
603
|
+
const currentPath = path.join(CONTEXT_DIR, "current.json");
|
|
604
|
+
if (await fs.pathExists(currentPath)) {
|
|
605
|
+
existing = await fs.readJson(currentPath).catch(() => ({}));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const isEdit = !!existing.taskId;
|
|
609
|
+
if (isEdit) {
|
|
610
|
+
console.log(chalk.cyan("\nEdit Context\n"));
|
|
611
|
+
console.log(
|
|
612
|
+
chalk.gray("Press Enter to keep existing value shown in [brackets]\n"),
|
|
613
|
+
);
|
|
614
|
+
} else {
|
|
615
|
+
console.log(chalk.cyan("\nManual Context Entry\n"));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Ticket ID
|
|
619
|
+
const defaultId = prefillId || existing.taskId || "";
|
|
620
|
+
const idHint = defaultId ? chalk.gray(` [${defaultId}]`) : "";
|
|
621
|
+
const idInput = await input({
|
|
622
|
+
message: `Ticket ID (e.g. PROJ-33)${idHint}:`,
|
|
623
|
+
default: "",
|
|
624
|
+
});
|
|
625
|
+
const taskId = idInput.trim() || defaultId;
|
|
626
|
+
|
|
627
|
+
// Title
|
|
628
|
+
const defaultTitle = existing.title || "";
|
|
629
|
+
const titlePreview =
|
|
630
|
+
defaultTitle.length > 50
|
|
631
|
+
? defaultTitle.substring(0, 50) + "…"
|
|
632
|
+
: defaultTitle;
|
|
633
|
+
const titleHint = defaultTitle ? chalk.gray(` [${titlePreview}]`) : "";
|
|
634
|
+
const titleInput = await input({
|
|
635
|
+
message: `Title${titleHint}:`,
|
|
636
|
+
default: "",
|
|
637
|
+
});
|
|
638
|
+
const title = titleInput.trim() || defaultTitle;
|
|
639
|
+
|
|
640
|
+
// Description
|
|
641
|
+
const defaultDesc = existing.description || "";
|
|
642
|
+
const descPreview =
|
|
643
|
+
defaultDesc.length > 60 ? defaultDesc.substring(0, 60) + "…" : defaultDesc;
|
|
644
|
+
const descHint = defaultDesc ? chalk.gray(` [${descPreview}]`) : "";
|
|
645
|
+
const descInput = await input({
|
|
646
|
+
message: `Description (brief)${descHint}:`,
|
|
647
|
+
default: "",
|
|
648
|
+
});
|
|
649
|
+
const description = descInput.trim() || defaultDesc;
|
|
650
|
+
|
|
651
|
+
// Task type — pre-select existing value if available
|
|
652
|
+
const taskType = await select({
|
|
653
|
+
message: "Task type:",
|
|
654
|
+
choices: [
|
|
655
|
+
{ name: "🐛 Bug Fix", value: "bug-fix" },
|
|
656
|
+
{ name: "✨ Feature", value: "feature" },
|
|
657
|
+
{ name: "🔍 Investigation", value: "investigation" },
|
|
658
|
+
{ name: "♻️ Refactor", value: "refactor" },
|
|
659
|
+
{ name: "📊 Impact Analysis", value: "impact-analysis" },
|
|
660
|
+
{ name: "📖 Documentation", value: "documentation" },
|
|
661
|
+
],
|
|
662
|
+
default: existing.taskType || undefined,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const context = {
|
|
666
|
+
taskId,
|
|
667
|
+
taskType,
|
|
668
|
+
title,
|
|
669
|
+
description,
|
|
670
|
+
status: existing.status || "In Progress",
|
|
671
|
+
mode: existing.mode || "auto",
|
|
672
|
+
acceptanceCriteria: existing.acceptanceCriteria || [],
|
|
673
|
+
context: existing.context || { files: [], relatedTickets: [] },
|
|
674
|
+
metadata: {
|
|
675
|
+
created: existing.metadata?.created || new Date().toISOString(),
|
|
676
|
+
updated: new Date().toISOString(),
|
|
677
|
+
loadedFrom: "manual",
|
|
678
|
+
adapter: "manual",
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
await saveContext(context);
|
|
683
|
+
printContextSummary(context);
|
|
684
|
+
await suggestNextStep();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ──────────────────────────────────────────────────────────────
|
|
688
|
+
// Load from JSON file
|
|
689
|
+
// ──────────────────────────────────────────────────────────────
|
|
690
|
+
|
|
691
|
+
async function loadFromFile(filePath, options = {}) {
|
|
692
|
+
if (!(await fs.pathExists(filePath))) {
|
|
693
|
+
console.log(chalk.red(`File not found: ${filePath}`));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
698
|
+
let context;
|
|
699
|
+
|
|
700
|
+
// Try JSON first; fall back to plain-text auto-detection
|
|
701
|
+
try {
|
|
702
|
+
context = JSON.parse(raw);
|
|
703
|
+
} catch (_) {
|
|
704
|
+
console.log(
|
|
705
|
+
chalk.gray(
|
|
706
|
+
" File is not JSON — auto-detecting task info from content...",
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
context = buildContextFromPlainText(raw, filePath);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
context.mode = options.full ? "full" : "fast";
|
|
713
|
+
|
|
714
|
+
// Prompt for task type
|
|
715
|
+
context.taskType = await select({
|
|
716
|
+
message: "Task type:",
|
|
717
|
+
choices: [
|
|
718
|
+
{ name: "🐛 Bug Fix", value: "bug-fix" },
|
|
719
|
+
{ name: "✨ Feature", value: "feature" },
|
|
720
|
+
{ name: "🔍 Investigation", value: "investigation" },
|
|
721
|
+
{ name: "♻️ Refactor", value: "refactor" },
|
|
722
|
+
{ name: "📊 Impact Analysis", value: "impact-analysis" },
|
|
723
|
+
{ name: "📖 Documentation", value: "documentation" },
|
|
724
|
+
],
|
|
725
|
+
default: context.taskType || "feature",
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
await saveContext(context, options.save);
|
|
729
|
+
printContextSummary(context);
|
|
730
|
+
await suggestNextStep();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Build a minimal context object from arbitrary plain-text file content.
|
|
735
|
+
* Tries to detect ticket ID (e.g. PROJ-33, APP-123) and a title from the first non-blank line.
|
|
736
|
+
*/
|
|
737
|
+
function buildContextFromPlainText(text, filePath = "") {
|
|
738
|
+
// Detect ticket ID: first PROJ-123 style token anywhere in the text
|
|
739
|
+
const idMatch = text.match(/\b([A-Z][A-Z0-9_]+-\d+)\b/);
|
|
740
|
+
|
|
741
|
+
let taskId;
|
|
742
|
+
if (idMatch) {
|
|
743
|
+
taskId = idMatch[1];
|
|
744
|
+
} else {
|
|
745
|
+
const fileNameOnly = filePath
|
|
746
|
+
? path.basename(filePath, path.extname(filePath))
|
|
747
|
+
: "";
|
|
748
|
+
const cleanWords = fileNameOnly
|
|
749
|
+
.replace(/[^a-zA-Z0-9]/g, " ")
|
|
750
|
+
.split(/\s+/)
|
|
751
|
+
.filter(Boolean)
|
|
752
|
+
.slice(0, 5)
|
|
753
|
+
.join("-")
|
|
754
|
+
.toUpperCase();
|
|
755
|
+
const prefix = cleanWords ? `TASK-${cleanWords}-` : "TASK-";
|
|
756
|
+
taskId = `${prefix}${Date.now()}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Title: use full filename if available, otherwise first non-blank line
|
|
760
|
+
const fullFileName = filePath ? path.basename(filePath) : "";
|
|
761
|
+
const lines = text
|
|
762
|
+
.split(/\r?\n/)
|
|
763
|
+
.map((l) => l.trim())
|
|
764
|
+
.filter(Boolean);
|
|
765
|
+
const titleLine = fullFileName || lines.find((l) => l.length > 3) || taskId;
|
|
766
|
+
const title = titleLine.substring(0, 120);
|
|
767
|
+
|
|
768
|
+
// Task type detection from text
|
|
769
|
+
const taskType = detectTaskTypeFromString(text);
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
taskId,
|
|
773
|
+
taskType,
|
|
774
|
+
title,
|
|
775
|
+
description: text.substring(0, 3000),
|
|
776
|
+
status: "In Progress",
|
|
777
|
+
assignee: "Unassigned",
|
|
778
|
+
acceptanceCriteria: extractCriteria(text),
|
|
779
|
+
context: {
|
|
780
|
+
files: extractFiles(text),
|
|
781
|
+
relatedTickets: extractRelatedTickets(text),
|
|
782
|
+
},
|
|
783
|
+
metadata: {
|
|
784
|
+
created: new Date().toISOString(),
|
|
785
|
+
updated: new Date().toISOString(),
|
|
786
|
+
loadedFrom: "file",
|
|
787
|
+
adapter: "file",
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ──────────────────────────────────────────────────────────────
|
|
793
|
+
// Credentials loader — global (~/.aiflow/credentials.json) with project fallback
|
|
794
|
+
// ──────────────────────────────────────────────────────────────
|
|
795
|
+
|
|
796
|
+
async function loadCredentials() {
|
|
797
|
+
const globalCredsFile = path.join(
|
|
798
|
+
os.homedir(),
|
|
799
|
+
".aiflow",
|
|
800
|
+
"credentials.json",
|
|
801
|
+
);
|
|
802
|
+
if (await fs.pathExists(globalCredsFile)) {
|
|
803
|
+
const data = await fs.readJson(globalCredsFile).catch(() => ({}));
|
|
804
|
+
if (data.mcp && Object.keys(data.mcp).length > 0) return data.mcp;
|
|
805
|
+
}
|
|
806
|
+
// Fallback: project-level credentials (legacy support)
|
|
807
|
+
const projectCredsFile = path.join(
|
|
808
|
+
PROJECT_DIR,
|
|
809
|
+
".aiflow",
|
|
810
|
+
"credentials.json",
|
|
811
|
+
);
|
|
812
|
+
if (await fs.pathExists(projectCredsFile)) {
|
|
813
|
+
return await fs.readJson(projectCredsFile).catch(() => ({}));
|
|
814
|
+
}
|
|
815
|
+
return {};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ──────────────────────────────────────────────────────────────
|
|
819
|
+
// Context persistence
|
|
820
|
+
// ──────────────────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
async function saveContext(context, saveName) {
|
|
823
|
+
await fs.ensureDir(CONTEXT_DIR);
|
|
824
|
+
await fs.ensureDir(path.join(CONTEXT_DIR, "history"));
|
|
825
|
+
|
|
826
|
+
// Auto-pause any currently active task that differs from this one
|
|
827
|
+
const currentFile = path.join(CONTEXT_DIR, "current.json");
|
|
828
|
+
if (context.taskId && (await fs.pathExists(currentFile))) {
|
|
829
|
+
const prev = await fs.readJson(currentFile).catch(() => null);
|
|
830
|
+
if (prev && prev.taskId && prev.taskId !== context.taskId) {
|
|
831
|
+
try {
|
|
832
|
+
const { createOrActivateTaskState } = require("./task");
|
|
833
|
+
// Save previous task state as pending before switching
|
|
834
|
+
const prevTaskDir = path.join(AIFLOW_DIR, "tasks", prev.taskId);
|
|
835
|
+
const prevStatePath = path.join(prevTaskDir, "task-state.json");
|
|
836
|
+
await fs.ensureDir(prevTaskDir);
|
|
837
|
+
await fs.writeJson(path.join(prevTaskDir, "context.json"), prev, {
|
|
838
|
+
spaces: 2,
|
|
839
|
+
});
|
|
840
|
+
const prevState = (await fs.pathExists(prevStatePath))
|
|
841
|
+
? await fs.readJson(prevStatePath).catch(() => ({}))
|
|
842
|
+
: {};
|
|
843
|
+
const now = new Date().toISOString();
|
|
844
|
+
await fs.writeJson(
|
|
845
|
+
prevStatePath,
|
|
846
|
+
{
|
|
847
|
+
...prevState,
|
|
848
|
+
taskId: prev.taskId,
|
|
849
|
+
title: prev.title || "",
|
|
850
|
+
status: "pending",
|
|
851
|
+
updatedAt: now,
|
|
852
|
+
pausedAt: now,
|
|
853
|
+
currentGate: prevState.currentGate || 1,
|
|
854
|
+
gateApprovals: prevState.gateApprovals || {},
|
|
855
|
+
notes: prevState.notes || "",
|
|
856
|
+
createdAt: prevState.createdAt || now,
|
|
857
|
+
},
|
|
858
|
+
{ spaces: 2 },
|
|
859
|
+
);
|
|
860
|
+
console.log(chalk.gray(` Auto-paused previous task: ${prev.taskId}`));
|
|
861
|
+
} catch (_) {
|
|
862
|
+
/* task module not available */
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Save as current
|
|
868
|
+
await fs.writeJson(currentFile, context, { spaces: 2 });
|
|
869
|
+
|
|
870
|
+
// Save to history
|
|
871
|
+
const histFile = path.join(
|
|
872
|
+
CONTEXT_DIR,
|
|
873
|
+
"history",
|
|
874
|
+
`${context.taskId || "manual"}.json`,
|
|
875
|
+
);
|
|
876
|
+
await fs.writeJson(histFile, context, { spaces: 2 });
|
|
877
|
+
|
|
878
|
+
// Save named snapshot if requested
|
|
879
|
+
if (saveName) {
|
|
880
|
+
const namedFile = path.join(CONTEXT_DIR, "history", `${saveName}.json`);
|
|
881
|
+
await fs.writeJson(namedFile, context, { spaces: 2 });
|
|
882
|
+
console.log(chalk.gray(` Context also saved as: ${saveName}`));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Create / activate task-state for the new task
|
|
886
|
+
if (context.taskId) {
|
|
887
|
+
try {
|
|
888
|
+
const { createOrActivateTaskState } = require("./task");
|
|
889
|
+
await createOrActivateTaskState(context);
|
|
890
|
+
} catch (_) {
|
|
891
|
+
/* task module not available */
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Update state
|
|
896
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
897
|
+
const state = await fs.readJson(STATE_FILE);
|
|
898
|
+
state.current_context = context.taskId;
|
|
899
|
+
state.current_context_type = context.taskType;
|
|
900
|
+
await fs.writeJson(STATE_FILE, state);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ──────────────────────────────────────────────────────────────
|
|
905
|
+
// Helpers
|
|
906
|
+
// ──────────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
function detectTaskType(issue) {
|
|
909
|
+
const name = issue.issueType?.name || issue.type?.name || "";
|
|
910
|
+
const summary = issue.summary || "";
|
|
911
|
+
return detectTaskTypeFromString(name || summary);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function detectTaskTypeFromString(text) {
|
|
915
|
+
const lower = text.toLowerCase();
|
|
916
|
+
if (["bug", "defect", "バグ"].some((k) => lower.includes(k)))
|
|
917
|
+
return "bug-fix";
|
|
918
|
+
if (["task", "story", "feature", "タスク"].some((k) => lower.includes(k)))
|
|
919
|
+
return "feature";
|
|
920
|
+
if (["research", "investigation", "調査"].some((k) => lower.includes(k)))
|
|
921
|
+
return "investigation";
|
|
922
|
+
return "bug-fix"; // default for unknown
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function extractCriteria(text) {
|
|
926
|
+
const lines = text.split("\n");
|
|
927
|
+
return lines
|
|
928
|
+
.filter((l) => l.trim().match(/^[-*•]|^\d+\./))
|
|
929
|
+
.map((l) => l.replace(/^[-*•\d.]+\s*/, "").trim())
|
|
930
|
+
.filter((l) => l.length > 0)
|
|
931
|
+
.slice(0, 10);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function extractFiles(text) {
|
|
935
|
+
const matches = text.match(/[\w/]+\.(php|js|ts|vue|jsx|tsx|css|html)/g) || [];
|
|
936
|
+
return [...new Set(matches)];
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function extractRelatedTickets(text) {
|
|
940
|
+
const matches = text.match(/[A-Z][A-Z0-9_]+-\d+/g) || [];
|
|
941
|
+
return [...new Set(matches)];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function printContextSummary(context) {
|
|
945
|
+
console.log(chalk.green("\n✓ Context loaded\n"));
|
|
946
|
+
console.log(` ${chalk.white("Ticket:")} ${context.taskId}`);
|
|
947
|
+
console.log(` ${chalk.white("Type:")} ${context.taskType}`);
|
|
948
|
+
console.log(
|
|
949
|
+
` ${chalk.white("Title:")} ${context.title.substring(0, 70)}`,
|
|
950
|
+
);
|
|
951
|
+
console.log(` ${chalk.white("Status:")} ${context.status}`);
|
|
952
|
+
console.log(` ${chalk.white("Assignee:")} ${context.assignee}`);
|
|
953
|
+
if (context.description) {
|
|
954
|
+
console.log(
|
|
955
|
+
` ${chalk.white("Desc:")} ${context.description.substring(0, 80)}...`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (context.acceptanceCriteria?.length) {
|
|
959
|
+
console.log(
|
|
960
|
+
` ${chalk.white("Criteria:")} ${context.acceptanceCriteria.length} item(s)`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (context.commentCount > 0) {
|
|
964
|
+
console.log(
|
|
965
|
+
` ${chalk.white("Comments:")} ${context.commentCount} comment(s) loaded`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
console.log();
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ── GitNexus index status notification ───────────────────────────
|
|
972
|
+
// Called at the top of use() so ALL AI tools (Claude/Gemini/Cursor/Copilot)
|
|
973
|
+
// see the notification when developer runs `aiflow use TICKET`.
|
|
974
|
+
const GITNEXUS_STALE_MS = 3 * 60 * 60 * 1000; // 3 hours — if still "running" after this, process died
|
|
975
|
+
|
|
976
|
+
async function checkGitNexusIndexStatus() {
|
|
977
|
+
const statusPath = path.join(AIFLOW_DIR, "gitnexus-status.json");
|
|
978
|
+
try {
|
|
979
|
+
if (!(await fs.pathExists(statusPath))) return;
|
|
980
|
+
const gn = await fs.readJson(statusPath);
|
|
981
|
+
if (gn.notified) return;
|
|
982
|
+
|
|
983
|
+
// Detect stale "running" — process was killed without updating status
|
|
984
|
+
if (gn.status === "running" && gn.startedAt) {
|
|
985
|
+
const age = Date.now() - new Date(gn.startedAt).getTime();
|
|
986
|
+
if (age > GITNEXUS_STALE_MS) {
|
|
987
|
+
gn.status = "error";
|
|
988
|
+
gn.error =
|
|
989
|
+
"timed out — process may have been killed or ran out of memory";
|
|
990
|
+
gn.notified = false;
|
|
991
|
+
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (gn.status === "done") {
|
|
996
|
+
console.log(
|
|
997
|
+
chalk.green(
|
|
998
|
+
"✓ GitNexus indexing complete — code intelligence tools are ready.",
|
|
999
|
+
),
|
|
1000
|
+
);
|
|
1001
|
+
gn.notified = true;
|
|
1002
|
+
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
1003
|
+
} else if (gn.status === "error") {
|
|
1004
|
+
const detail = gn.error
|
|
1005
|
+
? ` (${gn.error})`
|
|
1006
|
+
: gn.exitCode
|
|
1007
|
+
? ` (exit ${gn.exitCode})`
|
|
1008
|
+
: "";
|
|
1009
|
+
console.log(chalk.red(`✗ GitNexus indexing failed${detail}.`));
|
|
1010
|
+
// console.log(chalk.gray(' Retry: aiflow init --with-gitnexus'));
|
|
1011
|
+
gn.notified = true;
|
|
1012
|
+
await fs.writeJson(statusPath, gn, { spaces: 2 });
|
|
1013
|
+
}
|
|
1014
|
+
// status === 'running' and not yet stale: still in progress, stay silent
|
|
1015
|
+
} catch (_) {}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function suggestNextStep() {
|
|
1019
|
+
let aiTools = ["claude"]; // Default
|
|
1020
|
+
try {
|
|
1021
|
+
if (await fs.pathExists(STATE_FILE)) {
|
|
1022
|
+
const state = await fs.readJson(STATE_FILE);
|
|
1023
|
+
if (state.aiTools && state.aiTools.length) {
|
|
1024
|
+
aiTools = state.aiTools;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
} catch (_) {}
|
|
1028
|
+
|
|
1029
|
+
console.log(chalk.cyan("\nNext Steps:"));
|
|
1030
|
+
|
|
1031
|
+
const hasCLI = aiTools.includes("claude") || aiTools.includes("gemini");
|
|
1032
|
+
const hasIDE =
|
|
1033
|
+
aiTools.includes("claude") ||
|
|
1034
|
+
aiTools.includes("cursor") ||
|
|
1035
|
+
aiTools.includes("copilot");
|
|
1036
|
+
|
|
1037
|
+
if (hasCLI) {
|
|
1038
|
+
console.log(chalk.white("\n CLI:"));
|
|
1039
|
+
let step = 0;
|
|
1040
|
+
if (aiTools.includes("claude")) {
|
|
1041
|
+
console.log(
|
|
1042
|
+
` ${++step}. ${chalk.white("Claude:")} Run ${chalk.bold.green("claude start")} in terminal. ${chalk.gray("(Quickest way to start)")}`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
if (aiTools.includes("gemini")) {
|
|
1046
|
+
console.log(
|
|
1047
|
+
` ${++step}. ${chalk.white("Gemini:")} Run ${chalk.bold.green("gemini")} then type ${chalk.bold.green('"start"')} or ${chalk.bold.green('"Gate 1"')}.`,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (hasIDE) {
|
|
1053
|
+
console.log(chalk.white("\n IDE Extension Chat:"));
|
|
1054
|
+
console.log(
|
|
1055
|
+
` Run ${chalk.bold.green("aiflow prompt")} → prompt auto-copied to clipboard → paste into your AI extension chat.`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
console.log(
|
|
1060
|
+
`\n ${chalk.yellow("Note:")} If the AI does not start automatically, type ${chalk.bold.green('"Gate 1"')} or ${chalk.bold.green('"Analyze ticket"')} to begin.`,
|
|
1061
|
+
);
|
|
1062
|
+
console.log();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function loadTargetAsSupplementaryContext(target, creds, alreadyIds) {
|
|
1066
|
+
if (!target) return null;
|
|
1067
|
+
|
|
1068
|
+
// File path (not a ticket ID, not a backlog URL)
|
|
1069
|
+
if (!isTicketId(target) && !isBacklogUrl(target)) {
|
|
1070
|
+
try {
|
|
1071
|
+
const raw = await fs.readFile(target, 'utf-8');
|
|
1072
|
+
return {
|
|
1073
|
+
sourceType: 'file',
|
|
1074
|
+
sourcePath: target,
|
|
1075
|
+
title: path.basename(target),
|
|
1076
|
+
content: raw.substring(0, 3000),
|
|
1077
|
+
};
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.log(chalk.yellow(` ⚠ Could not read file: ${target}`));
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const issueKey = isBacklogUrl(target) ? extractBacklogId(target) : target;
|
|
1085
|
+
if (!issueKey || alreadyIds.includes(issueKey)) return null;
|
|
1086
|
+
|
|
1087
|
+
const adapter = await resolveTicketAdapter();
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
if (adapter === 'jira') {
|
|
1091
|
+
const token = creds.JIRA_API_TOKEN;
|
|
1092
|
+
const email = creds.JIRA_EMAIL;
|
|
1093
|
+
const domain = creds.JIRA_DOMAIN;
|
|
1094
|
+
if (!token || !email || !domain) return null;
|
|
1095
|
+
const issue = await fetchJiraIssue(domain, email, token, issueKey);
|
|
1096
|
+
const fields = issue.fields || {};
|
|
1097
|
+
const desc = (fields.description?.content || [])
|
|
1098
|
+
.map(b => (b.content || []).map(c => c.text || '').join('')).join('\n');
|
|
1099
|
+
return {
|
|
1100
|
+
sourceType: 'ticket',
|
|
1101
|
+
sourceUrl: `https://${domain}.atlassian.net/browse/${issueKey}`,
|
|
1102
|
+
ticketId: issueKey,
|
|
1103
|
+
title: fields.summary || '',
|
|
1104
|
+
description: desc.substring(0, 2000),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
const apiKey = creds.BACKLOG_API_KEY;
|
|
1108
|
+
const spaceKey = creds.BACKLOG_SPACE_KEY || creds.BACKLOG_DOMAIN;
|
|
1109
|
+
if (!apiKey || !spaceKey) return null;
|
|
1110
|
+
const domain = spaceKey.includes('.') ? spaceKey : `${spaceKey}.backlog.com`;
|
|
1111
|
+
const issue = await fetchBacklogIssue(domain, apiKey, issueKey);
|
|
1112
|
+
return {
|
|
1113
|
+
sourceType: 'ticket',
|
|
1114
|
+
sourceUrl: `https://${domain}/view/${issueKey}`,
|
|
1115
|
+
ticketId: issueKey,
|
|
1116
|
+
title: issue.summary || '',
|
|
1117
|
+
description: (issue.description || '').substring(0, 2000),
|
|
1118
|
+
};
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.log(chalk.yellow(` ⚠ Could not load supplementary ${issueKey}: ${err.message}`));
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function enrichWithSupplementary(supplementaryTargets) {
|
|
1126
|
+
const currentFile = path.join(CONTEXT_DIR, 'current.json');
|
|
1127
|
+
if (!(await fs.pathExists(currentFile))) return;
|
|
1128
|
+
|
|
1129
|
+
const ctx = await fs.readJson(currentFile).catch(() => null);
|
|
1130
|
+
if (!ctx) return;
|
|
1131
|
+
|
|
1132
|
+
const creds = await loadCredentials();
|
|
1133
|
+
const supplementary = [];
|
|
1134
|
+
const alreadyIds = [ctx.taskId].filter(Boolean);
|
|
1135
|
+
|
|
1136
|
+
for (const t of supplementaryTargets) {
|
|
1137
|
+
const sc = await loadTargetAsSupplementaryContext(t, creds, alreadyIds);
|
|
1138
|
+
if (sc) {
|
|
1139
|
+
supplementary.push(sc);
|
|
1140
|
+
if (sc.ticketId) alreadyIds.push(sc.ticketId);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (ctx.description) {
|
|
1145
|
+
const { resolveLinks } = require('./link-resolver');
|
|
1146
|
+
const linked = await resolveLinks(ctx.description, creds, alreadyIds);
|
|
1147
|
+
supplementary.push(...linked);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (supplementary.length > 0) {
|
|
1151
|
+
ctx.supplementaryContext = supplementary;
|
|
1152
|
+
await fs.writeJson(currentFile, ctx, { spaces: 2 });
|
|
1153
|
+
const histFile = path.join(CONTEXT_DIR, 'history', `${ctx.taskId || 'manual'}.json`);
|
|
1154
|
+
if (await fs.pathExists(path.dirname(histFile))) {
|
|
1155
|
+
await fs.writeJson(histFile, ctx, { spaces: 2 });
|
|
1156
|
+
}
|
|
1157
|
+
console.log(chalk.green(` ✓ ${supplementary.length} supplementary context(s) linked`));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
module.exports = useCommand;
|
|
1162
|
+
module.exports.loadCredentials = loadCredentials;
|