@love-moon/conductor-cli 0.2.41 → 0.3.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/bin/conductor-fire.js +135 -19
- package/bin/conductor-issue.js +357 -0
- package/bin/conductor-project.js +436 -0
- package/bin/conductor-task.js +285 -0
- package/bin/conductor.js +25 -1
- package/package.json +11 -6
- package/src/ai-manager-handlers.js +17 -1
- package/src/daemon.js +795 -35
- package/src/entity-helpers.js +345 -0
- package/src/fire/resume.js +113 -870
- package/src/runtime-backends.js +48 -8
package/bin/conductor-fire.js
CHANGED
|
@@ -848,6 +848,10 @@ async function main() {
|
|
|
848
848
|
let nextInitialPrompt =
|
|
849
849
|
taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "";
|
|
850
850
|
let pendingRefreshSessionRequest = null;
|
|
851
|
+
// Only the first iteration should deliver the configured pre_prompt; any
|
|
852
|
+
// subsequent refresh-session rebuild must skip it so users don't see the
|
|
853
|
+
// pre_prompt bubble duplicated.
|
|
854
|
+
let nextShouldProcessPrePrompt = Boolean(taskContext.shouldProcessPrePrompt);
|
|
851
855
|
|
|
852
856
|
const sessionCommandLine = resolveAiSessionCommandLine(
|
|
853
857
|
cliArgs.backend,
|
|
@@ -930,7 +934,6 @@ async function main() {
|
|
|
930
934
|
...(cliArgs.sessionOptions || {}),
|
|
931
935
|
...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
|
|
932
936
|
logger: { log },
|
|
933
|
-
...(resolvedPrePrompt ? { prePrompt: resolvedPrePrompt } : {}),
|
|
934
937
|
sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
|
|
935
938
|
resumePersistedSession: Boolean(!nextResumeSessionId && taskContext.taskId),
|
|
936
939
|
});
|
|
@@ -947,6 +950,8 @@ async function main() {
|
|
|
947
950
|
backendName: cliArgs.backend,
|
|
948
951
|
resumeSessionId: nextResumeSessionId,
|
|
949
952
|
daemonName: resolvedDaemonName,
|
|
953
|
+
prePrompt: resolvedPrePrompt || "",
|
|
954
|
+
shouldProcessPrePrompt: Boolean(resolvedPrePrompt) && nextShouldProcessPrePrompt,
|
|
950
955
|
});
|
|
951
956
|
reconnectRunner = runner;
|
|
952
957
|
if (pendingRemoteStopEvent) {
|
|
@@ -993,6 +998,7 @@ async function main() {
|
|
|
993
998
|
if (refreshedSessionId) {
|
|
994
999
|
nextResumeSessionId = refreshedSessionId;
|
|
995
1000
|
nextInitialPrompt = "";
|
|
1001
|
+
nextShouldProcessPrePrompt = false;
|
|
996
1002
|
pendingRefreshSessionRequest = refreshSessionRequest;
|
|
997
1003
|
continue;
|
|
998
1004
|
}
|
|
@@ -1460,13 +1466,34 @@ function buildEnv() {
|
|
|
1460
1466
|
return env;
|
|
1461
1467
|
}
|
|
1462
1468
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1469
|
+
function isBackendNotFoundError(error) {
|
|
1470
|
+
return Boolean(error && typeof error === "object" && Number(error.statusCode) === 404);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
export async function ensureTaskContext(conductor, opts) {
|
|
1474
|
+
const providedTaskId =
|
|
1475
|
+
typeof opts.providedTaskId === "string" ? opts.providedTaskId.trim() : "";
|
|
1476
|
+
if (providedTaskId) {
|
|
1477
|
+
if (typeof conductor.getTask === "function") {
|
|
1478
|
+
try {
|
|
1479
|
+
await conductor.getTask(providedTaskId);
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
if (isBackendNotFoundError(error)) {
|
|
1482
|
+
throw new Error(
|
|
1483
|
+
`CONDUCTOR_TASK_ID points to missing task ${providedTaskId}; unset CONDUCTOR_TASK_ID or use an existing task id`,
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1465
1489
|
return {
|
|
1466
|
-
taskId:
|
|
1490
|
+
taskId: providedTaskId,
|
|
1467
1491
|
appUrl: null,
|
|
1468
1492
|
shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
|
|
1469
1493
|
initialPromptDelivery: opts.initialPrompt ? "synthetic" : "none",
|
|
1494
|
+
// Daemon provided the task id, so this fire process is the first attach
|
|
1495
|
+
// for that task — it should inject the configured pre_prompt once.
|
|
1496
|
+
shouldProcessPrePrompt: true,
|
|
1470
1497
|
};
|
|
1471
1498
|
}
|
|
1472
1499
|
|
|
@@ -1493,6 +1520,9 @@ async function ensureTaskContext(conductor, opts) {
|
|
|
1493
1520
|
appUrl: session.app_url || null,
|
|
1494
1521
|
shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
|
|
1495
1522
|
initialPromptDelivery: opts.initialPrompt ? "queued" : "none",
|
|
1523
|
+
// Newly created task — pre_prompt should run exactly once right after
|
|
1524
|
+
// the backend session is announced, before any real user message.
|
|
1525
|
+
shouldProcessPrePrompt: true,
|
|
1496
1526
|
};
|
|
1497
1527
|
}
|
|
1498
1528
|
|
|
@@ -1789,6 +1819,8 @@ export class BridgeRunner {
|
|
|
1789
1819
|
backendName,
|
|
1790
1820
|
resumeSessionId,
|
|
1791
1821
|
daemonName,
|
|
1822
|
+
prePrompt,
|
|
1823
|
+
shouldProcessPrePrompt,
|
|
1792
1824
|
}) {
|
|
1793
1825
|
this.backendSession = backendSession;
|
|
1794
1826
|
this.conductor = conductor;
|
|
@@ -1818,6 +1850,9 @@ export class BridgeRunner {
|
|
|
1818
1850
|
this.initialPromptDelivery === "queued" && typeof initialPrompt === "string" && initialPrompt.trim()
|
|
1819
1851
|
? initialPrompt.trim()
|
|
1820
1852
|
: "";
|
|
1853
|
+
this.prePrompt =
|
|
1854
|
+
typeof prePrompt === "string" && prePrompt.trim() ? prePrompt.trim() : "";
|
|
1855
|
+
this.shouldProcessPrePrompt = Boolean(shouldProcessPrePrompt) && Boolean(this.prePrompt);
|
|
1821
1856
|
this.sessionStreamReplyCounts = new Map();
|
|
1822
1857
|
this.lastRuntimeStatusSignature = null;
|
|
1823
1858
|
this.lastRuntimeStatusPayload = null;
|
|
@@ -2027,6 +2062,17 @@ export class BridgeRunner {
|
|
|
2027
2062
|
return;
|
|
2028
2063
|
}
|
|
2029
2064
|
|
|
2065
|
+
if (this.shouldProcessPrePrompt) {
|
|
2066
|
+
this.copilotLog(
|
|
2067
|
+
`processing pre_prompt via synthetic attach flow contentLen=${this.prePrompt.length}`,
|
|
2068
|
+
);
|
|
2069
|
+
this.shouldProcessPrePrompt = false;
|
|
2070
|
+
await this.handlePrePromptMessage(this.prePrompt);
|
|
2071
|
+
if (this.stopped) {
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2030
2076
|
if (this.initialPrompt && this.initialPromptDelivery === "synthetic") {
|
|
2031
2077
|
this.copilotLog("processing initial prompt via synthetic attach flow");
|
|
2032
2078
|
await this.handleSyntheticMessage(this.initialPrompt, {
|
|
@@ -3049,6 +3095,51 @@ export class BridgeRunner {
|
|
|
3049
3095
|
}
|
|
3050
3096
|
|
|
3051
3097
|
async handleSyntheticMessage(content, { includeImages }) {
|
|
3098
|
+
return this.runSyntheticTurn({
|
|
3099
|
+
content,
|
|
3100
|
+
replyTarget: "initial",
|
|
3101
|
+
includeImages: Boolean(includeImages),
|
|
3102
|
+
logTag: "synthetic",
|
|
3103
|
+
introLabel: "初始提示",
|
|
3104
|
+
errorLabel: "初始提示",
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
async handlePrePromptMessage(content) {
|
|
3109
|
+
const text = typeof content === "string" ? content.trim() : "";
|
|
3110
|
+
if (!text) {
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
return this.runSyntheticTurn({
|
|
3114
|
+
content: text,
|
|
3115
|
+
replyTarget: "pre_prompt",
|
|
3116
|
+
includeImages: false,
|
|
3117
|
+
logTag: "pre_prompt",
|
|
3118
|
+
introLabel: "pre_prompt",
|
|
3119
|
+
errorLabel: "pre_prompt",
|
|
3120
|
+
surfaceUserMessage: {
|
|
3121
|
+
content: text,
|
|
3122
|
+
metadata: {
|
|
3123
|
+
pre_prompt: true,
|
|
3124
|
+
role: "user",
|
|
3125
|
+
visible_as: "user",
|
|
3126
|
+
origin: "pre_prompt",
|
|
3127
|
+
},
|
|
3128
|
+
},
|
|
3129
|
+
replyMetadata: { pre_prompt_response: true },
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
async runSyntheticTurn({
|
|
3134
|
+
content,
|
|
3135
|
+
replyTarget,
|
|
3136
|
+
includeImages,
|
|
3137
|
+
logTag,
|
|
3138
|
+
introLabel,
|
|
3139
|
+
errorLabel,
|
|
3140
|
+
surfaceUserMessage,
|
|
3141
|
+
replyMetadata,
|
|
3142
|
+
}) {
|
|
3052
3143
|
this.lastRuntimeStatusSignature = null;
|
|
3053
3144
|
this.runningTurn = true;
|
|
3054
3145
|
const startedAt = Date.now();
|
|
@@ -3056,28 +3147,48 @@ export class BridgeRunner {
|
|
|
3056
3147
|
this.useSessionFileReplyStream &&
|
|
3057
3148
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
3058
3149
|
) {
|
|
3059
|
-
this.backendSession.setSessionReplyTarget(
|
|
3150
|
+
this.backendSession.setSessionReplyTarget(replyTarget);
|
|
3151
|
+
}
|
|
3152
|
+
this.copilotLog(
|
|
3153
|
+
`${logTag} turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`,
|
|
3154
|
+
);
|
|
3155
|
+
if (surfaceUserMessage) {
|
|
3156
|
+
try {
|
|
3157
|
+
await this.conductor.sendMessage(this.taskId, surfaceUserMessage.content, {
|
|
3158
|
+
backend: this.backendName,
|
|
3159
|
+
thread_id: this.backendSession?.threadId,
|
|
3160
|
+
cli_args: this.cliArgs,
|
|
3161
|
+
synthetic: true,
|
|
3162
|
+
...(surfaceUserMessage.metadata || {}),
|
|
3163
|
+
});
|
|
3164
|
+
this.copilotLog(
|
|
3165
|
+
`${logTag} message surfaced to task len=${surfaceUserMessage.content.length}`,
|
|
3166
|
+
);
|
|
3167
|
+
} catch (error) {
|
|
3168
|
+
log(`Failed to surface ${logTag} message: ${error?.message || error}`);
|
|
3169
|
+
}
|
|
3060
3170
|
}
|
|
3061
|
-
this.copilotLog(`synthetic turn start includeImages=${Boolean(includeImages)} contentLen=${String(content || "").length}`);
|
|
3062
3171
|
try {
|
|
3063
3172
|
const result = await this.backendSession.runTurn(content, {
|
|
3064
|
-
useInitialImages: includeImages,
|
|
3173
|
+
useInitialImages: Boolean(includeImages),
|
|
3065
3174
|
onProgress: (payload) => {
|
|
3066
|
-
void this.reportRuntimeStatus(payload,
|
|
3175
|
+
void this.reportRuntimeStatus(payload, replyTarget);
|
|
3067
3176
|
},
|
|
3068
3177
|
});
|
|
3069
3178
|
this.copilotLog(
|
|
3070
|
-
|
|
3179
|
+
`${logTag} runTurn completed elapsedMs=${Date.now() - startedAt} answerLen=${String(result.text || "").trim().length}`,
|
|
3071
3180
|
);
|
|
3072
3181
|
if (!this.useSessionFileReplyStream) {
|
|
3073
3182
|
const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
|
|
3074
|
-
const intro = `${backendLabel}
|
|
3183
|
+
const intro = `${backendLabel} 已根据${introLabel}给出回复:`;
|
|
3075
3184
|
const replyText =
|
|
3076
|
-
result.text ||
|
|
3185
|
+
result.text ||
|
|
3186
|
+
extractAgentTextFromItems(result.items) ||
|
|
3187
|
+
extractAgentTextFromMetadata(result.metadata);
|
|
3077
3188
|
const text = replyText ? `${intro}\n\n${replyText}` : intro;
|
|
3078
3189
|
logBackendReply(this.backendName, replyText || "(无文本输出)", {
|
|
3079
3190
|
usage: result.usage,
|
|
3080
|
-
replyTo:
|
|
3191
|
+
replyTo: replyTarget,
|
|
3081
3192
|
});
|
|
3082
3193
|
await this.conductor.sendMessage(this.taskId, text, {
|
|
3083
3194
|
model: this.backendSession.threadOptions?.model || this.backendName,
|
|
@@ -3086,27 +3197,32 @@ export class BridgeRunner {
|
|
|
3086
3197
|
thread_id: this.backendSession.threadId,
|
|
3087
3198
|
cli_args: this.cliArgs,
|
|
3088
3199
|
synthetic: true,
|
|
3200
|
+
...(replyMetadata || {}),
|
|
3089
3201
|
});
|
|
3090
|
-
this.copilotLog(
|
|
3202
|
+
this.copilotLog(`${logTag} sdk_message sent responseLen=${text.length}`);
|
|
3091
3203
|
} else {
|
|
3092
|
-
this.copilotLog(
|
|
3204
|
+
this.copilotLog(`${logTag} session_file turn settled`);
|
|
3093
3205
|
}
|
|
3094
3206
|
await this.syncBackendSessionBinding();
|
|
3095
3207
|
} catch (error) {
|
|
3096
3208
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3097
3209
|
if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
|
|
3098
|
-
this.copilotLog(
|
|
3210
|
+
this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
|
|
3099
3211
|
return;
|
|
3100
3212
|
}
|
|
3101
|
-
if (
|
|
3213
|
+
if (
|
|
3214
|
+
await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
|
|
3215
|
+
markProcessed: false,
|
|
3216
|
+
})
|
|
3217
|
+
) {
|
|
3102
3218
|
return;
|
|
3103
3219
|
}
|
|
3104
3220
|
this.copilotLog(
|
|
3105
|
-
|
|
3221
|
+
`${logTag} turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
3106
3222
|
);
|
|
3107
|
-
await this.reportError(
|
|
3223
|
+
await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
|
|
3108
3224
|
} finally {
|
|
3109
|
-
this.copilotLog(
|
|
3225
|
+
this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
|
|
3110
3226
|
this.runningTurn = false;
|
|
3111
3227
|
}
|
|
3112
3228
|
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor issue — entity-oriented issue management.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* list [--project ...] [--status <s>] [--limit N]
|
|
8
|
+
* show <id>
|
|
9
|
+
* create --title <t> [--description <d> | --description-file FILE | --description-stdin]
|
|
10
|
+
* [--priority P1|P2|P3] [--status backlog|doing|done]
|
|
11
|
+
* [--client-request-id <key>] [--project ...]
|
|
12
|
+
* update <id> [--title ...] [--description ...] [--priority ...] [--status ...]
|
|
13
|
+
* start <id> (alias for update --status doing)
|
|
14
|
+
* done <id> [--evidence <text>|@FILE]
|
|
15
|
+
*
|
|
16
|
+
* Global flags supported on every write subcommand:
|
|
17
|
+
* --json, --dry-run, --project, --config-file
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
import yargs from "yargs/yargs";
|
|
25
|
+
import { hideBin } from "yargs/helpers";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
EXIT,
|
|
29
|
+
buildApis,
|
|
30
|
+
buildAuditMetadata,
|
|
31
|
+
emitDryRun,
|
|
32
|
+
exitCodeForError,
|
|
33
|
+
makeDryRunPayload,
|
|
34
|
+
pad,
|
|
35
|
+
printJson,
|
|
36
|
+
printPretty,
|
|
37
|
+
readDescription,
|
|
38
|
+
readEvidence,
|
|
39
|
+
reportError,
|
|
40
|
+
resolveProject,
|
|
41
|
+
} from "../src/entity-helpers.js";
|
|
42
|
+
|
|
43
|
+
const isMainModule = (() => {
|
|
44
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
45
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
46
|
+
return entryFile === currentFile;
|
|
47
|
+
})();
|
|
48
|
+
|
|
49
|
+
function buildBaseUrl(config) {
|
|
50
|
+
const raw = (config?.backendUrl || "").replace(/\/+$/, "");
|
|
51
|
+
return raw || "http://localhost";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function issueAsObject(issue) {
|
|
55
|
+
if (!issue) return null;
|
|
56
|
+
if (typeof issue.asObject === "function") return issue.asObject();
|
|
57
|
+
return {
|
|
58
|
+
id: issue.id,
|
|
59
|
+
title: issue.title,
|
|
60
|
+
status: issue.status,
|
|
61
|
+
priority: issue.priority,
|
|
62
|
+
description: issue.description,
|
|
63
|
+
projectId: issue.projectId,
|
|
64
|
+
metadata: issue.metadata,
|
|
65
|
+
createdAt: issue.createdAt,
|
|
66
|
+
updatedAt: issue.updatedAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseStatusList(value) {
|
|
71
|
+
if (!value) return undefined;
|
|
72
|
+
return String(value)
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((entry) => entry.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleList(argv, deps) {
|
|
79
|
+
const apis = await buildApis(deps);
|
|
80
|
+
const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
|
|
81
|
+
const list = await apis.issues.listIssues({
|
|
82
|
+
projectId: project.id,
|
|
83
|
+
status: parseStatusList(argv.status),
|
|
84
|
+
limit: argv.limit ? Number(argv.limit) : undefined,
|
|
85
|
+
});
|
|
86
|
+
const objects = (Array.isArray(list) ? list : []).map(issueAsObject);
|
|
87
|
+
if (argv.json) {
|
|
88
|
+
printJson(deps.stdout, objects);
|
|
89
|
+
return EXIT.OK;
|
|
90
|
+
}
|
|
91
|
+
if (objects.length === 0) {
|
|
92
|
+
printPretty(deps.stdout, "(no issues)");
|
|
93
|
+
return EXIT.OK;
|
|
94
|
+
}
|
|
95
|
+
printPretty(deps.stdout, `${pad("ID", 24)} ${pad("STATUS", 9)} ${pad("PRIO", 5)} TITLE`);
|
|
96
|
+
for (const issue of objects) {
|
|
97
|
+
printPretty(
|
|
98
|
+
deps.stdout,
|
|
99
|
+
`${pad(issue.id, 24)} ${pad(issue.status, 9)} ${pad(issue.priority ?? "", 5)} ${issue.title ?? ""}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return EXIT.OK;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleShow(argv, deps) {
|
|
106
|
+
const apis = await buildApis(deps);
|
|
107
|
+
const issue = await apis.issues.getIssue(argv.id);
|
|
108
|
+
if (!issue) {
|
|
109
|
+
const err = new Error(`Issue not found: ${argv.id}`);
|
|
110
|
+
err.statusCode = 404;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const obj = issueAsObject(issue);
|
|
114
|
+
if (argv.json) {
|
|
115
|
+
printJson(deps.stdout, obj);
|
|
116
|
+
return EXIT.OK;
|
|
117
|
+
}
|
|
118
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
119
|
+
if (value === undefined || value === null) continue;
|
|
120
|
+
if (typeof value === "object") {
|
|
121
|
+
printPretty(deps.stdout, `${key}: ${JSON.stringify(value)}`);
|
|
122
|
+
} else {
|
|
123
|
+
printPretty(deps.stdout, `${key}: ${value}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return EXIT.OK;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function handleCreate(argv, deps) {
|
|
130
|
+
const apis = await buildApis(deps);
|
|
131
|
+
const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
|
|
132
|
+
const description = readDescription({
|
|
133
|
+
description: argv.description,
|
|
134
|
+
descriptionFile: argv.descriptionFile,
|
|
135
|
+
descriptionStdin: argv.descriptionStdin,
|
|
136
|
+
stdin: deps.stdin,
|
|
137
|
+
});
|
|
138
|
+
const metadata = buildAuditMetadata(deps.env);
|
|
139
|
+
const body = {
|
|
140
|
+
projectId: project.id,
|
|
141
|
+
title: String(argv.title),
|
|
142
|
+
...(description !== undefined ? { description } : {}),
|
|
143
|
+
...(argv.priority ? { priority: String(argv.priority) } : {}),
|
|
144
|
+
...(argv.status ? { status: String(argv.status) } : {}),
|
|
145
|
+
...(argv.clientRequestId ? { clientRequestId: String(argv.clientRequestId) } : {}),
|
|
146
|
+
metadata,
|
|
147
|
+
};
|
|
148
|
+
if (argv.dryRun) {
|
|
149
|
+
emitDryRun(
|
|
150
|
+
deps.stdout,
|
|
151
|
+
argv.json,
|
|
152
|
+
makeDryRunPayload("POST", `${buildBaseUrl(apis.config)}/api/issues`, body),
|
|
153
|
+
);
|
|
154
|
+
return EXIT.OK;
|
|
155
|
+
}
|
|
156
|
+
const created = await apis.issues.createIssue(body);
|
|
157
|
+
const obj = issueAsObject(created);
|
|
158
|
+
if (argv.json) {
|
|
159
|
+
printJson(deps.stdout, obj);
|
|
160
|
+
return EXIT.OK;
|
|
161
|
+
}
|
|
162
|
+
printPretty(deps.stdout, `Created issue ${obj.id}: ${obj.title}`);
|
|
163
|
+
return EXIT.OK;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function handleUpdate(argv, deps, overrides = {}) {
|
|
167
|
+
const apis = await buildApis(deps);
|
|
168
|
+
const description = readDescription({
|
|
169
|
+
description: argv.description,
|
|
170
|
+
descriptionFile: argv.descriptionFile,
|
|
171
|
+
descriptionStdin: argv.descriptionStdin,
|
|
172
|
+
stdin: deps.stdin,
|
|
173
|
+
});
|
|
174
|
+
// `--description ""` is intentionally treated as a no-op (readDescription
|
|
175
|
+
// returns undefined). To clear an existing description, the server expects
|
|
176
|
+
// an explicit `null` — exposing that requires a follow-up flag and is out of
|
|
177
|
+
// scope here (review M5 — current behavior accepted as documented).
|
|
178
|
+
const status = overrides.status || argv.status;
|
|
179
|
+
const evidence = overrides.evidence;
|
|
180
|
+
const metadata = buildAuditMetadata(deps.env);
|
|
181
|
+
const body = {
|
|
182
|
+
...(argv.title ? { title: String(argv.title) } : {}),
|
|
183
|
+
...(description !== undefined ? { description } : {}),
|
|
184
|
+
...(argv.priority ? { priority: String(argv.priority) } : {}),
|
|
185
|
+
...(status ? { status: String(status) } : {}),
|
|
186
|
+
metadata,
|
|
187
|
+
};
|
|
188
|
+
if (Object.keys(body).filter((key) => key !== "metadata").length === 0) {
|
|
189
|
+
const err = new Error("Nothing to update: pass at least one of --title/--description/--priority/--status");
|
|
190
|
+
err.code = "ARGS";
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
if (argv.dryRun) {
|
|
194
|
+
// Surface evidence in the preview body so the user sees what the SDK will
|
|
195
|
+
// merge into `metadata.qa.evidence` server-side. The real call still
|
|
196
|
+
// round-trips the existing metadata; this preview is an approximation.
|
|
197
|
+
const previewBody = evidence === undefined
|
|
198
|
+
? body
|
|
199
|
+
: {
|
|
200
|
+
...body,
|
|
201
|
+
metadata: {
|
|
202
|
+
...body.metadata,
|
|
203
|
+
qa: { ...((body.metadata && body.metadata.qa) || {}), evidence },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const previewOptions = evidence !== undefined
|
|
207
|
+
? { note: "preview omits server-side metadata round-trip; live PATCH merges existing metadata.qa fields" }
|
|
208
|
+
: {};
|
|
209
|
+
emitDryRun(
|
|
210
|
+
deps.stdout,
|
|
211
|
+
argv.json,
|
|
212
|
+
makeDryRunPayload(
|
|
213
|
+
"PATCH",
|
|
214
|
+
`${buildBaseUrl(apis.config)}/api/issues/${encodeURIComponent(argv.id)}`,
|
|
215
|
+
previewBody,
|
|
216
|
+
previewOptions,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
return EXIT.OK;
|
|
220
|
+
}
|
|
221
|
+
// Pick the SDK call:
|
|
222
|
+
// - If we have `evidence`, route through `updateIssueStatus` so the SDK
|
|
223
|
+
// round-trips the existing metadata and merges `qa.evidence` instead of
|
|
224
|
+
// clobbering. We pass `metadata` so the CLI's audit namespace flows
|
|
225
|
+
// through.
|
|
226
|
+
// - Otherwise call `updateIssue` so a multi-field patch (title + status)
|
|
227
|
+
// reaches the server intact (review B2: previously the CLI passed the
|
|
228
|
+
// full body as the SDK's third arg, which was ignored).
|
|
229
|
+
let updated;
|
|
230
|
+
if (evidence !== undefined && status && typeof apis.issues.updateIssueStatus === "function") {
|
|
231
|
+
updated = await apis.issues.updateIssueStatus(argv.id, String(status), {
|
|
232
|
+
evidence,
|
|
233
|
+
metadata,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
updated = await apis.issues.updateIssue(argv.id, body);
|
|
237
|
+
}
|
|
238
|
+
const obj = issueAsObject(updated);
|
|
239
|
+
if (argv.json) {
|
|
240
|
+
printJson(deps.stdout, obj);
|
|
241
|
+
return EXIT.OK;
|
|
242
|
+
}
|
|
243
|
+
printPretty(deps.stdout, `Updated issue ${obj.id}`);
|
|
244
|
+
return EXIT.OK;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
248
|
+
const stdout = deps.stdout || process.stdout;
|
|
249
|
+
const stderr = deps.stderr || process.stderr;
|
|
250
|
+
const env = deps.env || process.env;
|
|
251
|
+
const cwd = deps.cwd || process.cwd();
|
|
252
|
+
const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
|
|
253
|
+
const handlerDeps = { ...deps, stdout, stderr, env, cwd };
|
|
254
|
+
|
|
255
|
+
let exitCode = EXIT.OK;
|
|
256
|
+
try {
|
|
257
|
+
await yargs(argvInput)
|
|
258
|
+
.scriptName("conductor issue")
|
|
259
|
+
.strict()
|
|
260
|
+
.help()
|
|
261
|
+
.option("json", { type: "boolean", default: false })
|
|
262
|
+
.option("dry-run", { type: "boolean", default: false })
|
|
263
|
+
.option("project", { type: "string", describe: "Project id or name override" })
|
|
264
|
+
.option("config-file", { type: "string", describe: "Path to Conductor config file" })
|
|
265
|
+
.command(
|
|
266
|
+
"list",
|
|
267
|
+
"List issues",
|
|
268
|
+
(cmd) => cmd
|
|
269
|
+
.option("status", { type: "string", describe: "Comma-separated status filter (e.g. backlog,doing)" })
|
|
270
|
+
.option("limit", { type: "number" }),
|
|
271
|
+
async (argv) => {
|
|
272
|
+
exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
.command(
|
|
276
|
+
"show <id>",
|
|
277
|
+
"Show one issue's full detail",
|
|
278
|
+
(cmd) => cmd.positional("id", { type: "string", demandOption: true }),
|
|
279
|
+
async (argv) => {
|
|
280
|
+
exitCode = await handleShow(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
.command(
|
|
284
|
+
"create",
|
|
285
|
+
"Create a new issue",
|
|
286
|
+
(cmd) => cmd
|
|
287
|
+
.option("title", { type: "string", demandOption: true })
|
|
288
|
+
.option("description", { type: "string" })
|
|
289
|
+
.option("description-file", { type: "string" })
|
|
290
|
+
.option("description-stdin", { type: "boolean", default: false })
|
|
291
|
+
.option("priority", { choices: ["P1", "P2", "P3"] })
|
|
292
|
+
.option("status", { choices: ["backlog", "doing", "done"] })
|
|
293
|
+
.option("client-request-id", { type: "string" }),
|
|
294
|
+
async (argv) => {
|
|
295
|
+
exitCode = await handleCreate(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
.command(
|
|
299
|
+
"update <id>",
|
|
300
|
+
"Update an issue's fields (any subset)",
|
|
301
|
+
(cmd) => cmd
|
|
302
|
+
.positional("id", { type: "string", demandOption: true })
|
|
303
|
+
.option("title", { type: "string" })
|
|
304
|
+
.option("description", { type: "string" })
|
|
305
|
+
.option("description-file", { type: "string" })
|
|
306
|
+
.option("description-stdin", { type: "boolean", default: false })
|
|
307
|
+
.option("priority", { choices: ["P1", "P2", "P3"] })
|
|
308
|
+
.option("status", { choices: ["backlog", "doing", "done"] }),
|
|
309
|
+
async (argv) => {
|
|
310
|
+
exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
.command(
|
|
314
|
+
"start <id>",
|
|
315
|
+
"Mark issue as doing (alias for update --status doing)",
|
|
316
|
+
(cmd) => cmd.positional("id", { type: "string", demandOption: true }),
|
|
317
|
+
async (argv) => {
|
|
318
|
+
exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, { status: "doing" });
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
.command(
|
|
322
|
+
"done <id>",
|
|
323
|
+
"Mark issue as done (optionally attach QA evidence)",
|
|
324
|
+
(cmd) => cmd
|
|
325
|
+
.positional("id", { type: "string", demandOption: true })
|
|
326
|
+
.option("evidence", { type: "string", describe: "Inline text or @path/to/file" }),
|
|
327
|
+
async (argv) => {
|
|
328
|
+
const evidence = readEvidence(argv.evidence);
|
|
329
|
+
exitCode = await handleUpdate(argv, { ...handlerDeps, configFile: argv.configFile }, {
|
|
330
|
+
status: "done",
|
|
331
|
+
...(evidence !== undefined ? { evidence } : {}),
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
.demandCommand(1)
|
|
336
|
+
.fail((msg, err) => {
|
|
337
|
+
if (err) {
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
stderr.write(`${msg}\n`);
|
|
341
|
+
exitCode = EXIT.ARGS;
|
|
342
|
+
})
|
|
343
|
+
.parseAsync();
|
|
344
|
+
} catch (err) {
|
|
345
|
+
exitCode = reportError(consoleErr, err);
|
|
346
|
+
}
|
|
347
|
+
return exitCode;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (isMainModule) {
|
|
351
|
+
main().then((code) => {
|
|
352
|
+
if (code !== 0) process.exit(code);
|
|
353
|
+
}).catch((err) => {
|
|
354
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
355
|
+
process.exit(exitCodeForError(err));
|
|
356
|
+
});
|
|
357
|
+
}
|