@bridge_gpt/mcp-server 0.2.2 → 0.2.3
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 +97 -15
- package/build/agent-config-credential-migration.js +272 -0
- package/build/agents.generated.js +1 -1
- package/build/chain-orchestrator.js +16 -1
- package/build/commands.generated.js +9 -7
- package/build/conductor/bridge-api-client.js +625 -0
- package/build/conductor/claude-hook.js +251 -0
- package/build/conductor/cli.js +1048 -0
- package/build/conductor/data-normalization.js +114 -0
- package/build/conductor/doctor.js +164 -0
- package/build/conductor/done-gate.js +325 -0
- package/build/conductor/epic-reconcile.js +139 -0
- package/build/conductor/epic-runtime.js +611 -0
- package/build/conductor/epic-state.js +125 -0
- package/build/conductor/errors.js +85 -0
- package/build/conductor/git-ci-types.js +129 -0
- package/build/conductor/git-hooks.js +218 -0
- package/build/conductor/git-inspection.js +185 -0
- package/build/conductor/git-producer.js +137 -0
- package/build/conductor/merge-ledger.js +198 -0
- package/build/conductor/paths.js +224 -0
- package/build/conductor/plan.js +77 -0
- package/build/conductor/pr-ci-producer.js +427 -0
- package/build/conductor/pr-discovery.js +135 -0
- package/build/conductor/producer-ledger.js +125 -0
- package/build/conductor/redaction.js +112 -0
- package/build/conductor/store.js +1156 -0
- package/build/conductor/supervisor-config.js +150 -0
- package/build/conductor/supervisor-escalation.js +244 -0
- package/build/conductor/supervisor-judgment-python.js +141 -0
- package/build/conductor/supervisor-judgment.js +215 -0
- package/build/conductor/supervisor-ledger.js +119 -0
- package/build/conductor/supervisor-merge.js +127 -0
- package/build/conductor/supervisor-message-relay.js +61 -0
- package/build/conductor/supervisor-notification.js +39 -0
- package/build/conductor/supervisor-runtime.js +351 -0
- package/build/conductor/supervisor-state.js +572 -0
- package/build/conductor/supervisor-types.js +16 -0
- package/build/conductor/taxonomy.js +58 -0
- package/build/conductor/tools.js +367 -0
- package/build/conductor/types.js +9 -0
- package/build/conductor-bin.js +21 -0
- package/build/conductor-claude-hook-bin.js +21 -0
- package/build/credential-store.js +175 -4
- package/build/credentials-cli.js +223 -0
- package/build/decision-page-schema.js +60 -0
- package/build/decision-page-template.js +262 -10
- package/build/doctor.js +5 -1
- package/build/index.js +468 -59
- package/build/pipeline-orchestrator.js +5 -1
- package/build/pipeline-utils.js +45 -5
- package/build/pipelines.generated.js +37 -9
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +596 -0
- package/build/scheduled-prompt.js +16 -10
- package/build/start-tickets-conductor.js +496 -0
- package/build/start-tickets-prereqs.js +32 -23
- package/build/start-tickets-repo.js +49 -0
- package/build/start-tickets.js +682 -81
- package/build/version.generated.js +1 -1
- package/design-assets/favicon/android-chrome-192x192.png +0 -0
- package/design-assets/favicon/android-chrome-512x512.png +0 -0
- package/design-assets/favicon/apple-touch-icon.png +0 -0
- package/design-assets/favicon/favicon-16x16.png +0 -0
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/favicon/favicon.ico +0 -0
- package/design-assets/favicon/site.webmanifest +1 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
- package/package.json +17 -5
- package/pipelines/idea-to-ticket.json +5 -0
- package/pipelines/plan-epic.json +16 -1
- package/pipelines/review-ticket.json +2 -1
- package/public/css/main.min.css +2 -0
- package/public/css/main.min.css.map +1 -0
- package/public/fonts/OFL.txt +93 -0
- package/public/fonts/SourceSansPro-Black.ttf +0 -0
- package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Bold.ttf +0 -0
- package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
- package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Italic.ttf +0 -0
- package/public/fonts/SourceSansPro-Light.ttf +0 -0
- package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
- package/public/fonts/SourceSansPro-Regular.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
- package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
- package/public/img/bridge-logo-160x51.webp +0 -0
- package/public/img/bridge-logo-300x92.webp +0 -0
- package/public/img/favicon/android-chrome-192x192.png +0 -0
- package/public/img/favicon/android-chrome-512x512.png +0 -0
- package/public/img/favicon/apple-touch-icon.png +0 -0
- package/public/img/favicon/favicon-16x16.png +0 -0
- package/public/img/favicon/favicon-32x32.png +0 -0
- package/public/img/favicon/favicon.ico +0 -0
- package/public/img/favicon/site.webmanifest +1 -0
- package/public/img/installation/bitbucket/app-password-1.png +0 -0
- package/public/img/installation/bitbucket/app-password-2.png +0 -0
- package/public/img/installation/bitbucket/create-token-1.png +0 -0
- package/public/img/installation/bitbucket/create-token-2.png +0 -0
- package/public/img/installation/bitbucket/webhook-1.png +0 -0
- package/public/img/installation/github/github-review-webhook.png +0 -0
- package/public/img/installation/jira/credentials/api-key.png +0 -0
- package/public/img/installation/jira/webhook/create-rule.png +0 -0
- package/public/img/installation/jira/webhook/project-settings.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
- package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
- package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
- package/public/img/installation/pinecone/pinecone-index.png +0 -0
- package/public/js/main.min.js +2 -0
- package/public/js/main.min.js.map +1 -0
- package/smoke-test/SMOKE-TEST.md +16 -8
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentials-cli — the `credentials` subcommand, which hosts the consent-gated
|
|
3
|
+
* migration of a `BAPI_API_KEY` from an agent MCP config (`.mcp.json` /
|
|
4
|
+
* `.cursor/mcp.json`) into the user-scoped credential store.
|
|
5
|
+
*
|
|
6
|
+
* npx -y @bridge_gpt/mcp-server credentials migrate-agent-config \
|
|
7
|
+
* [--write-credentials|--no-write-credentials] \
|
|
8
|
+
* [--source=.mcp.json|--source=.cursor/mcp.json]
|
|
9
|
+
*
|
|
10
|
+
* This subcommand owns the WRITE path so the `doctor` subcommand can remain
|
|
11
|
+
* strictly read-only. Without `--write-credentials` it is a dry preview that
|
|
12
|
+
* writes nothing. The migrated key value is NEVER printed anywhere — diagnostics
|
|
13
|
+
* and prompts reference only candidate `filePath`/`serverName`.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, mkdir, writeFile, rename, chmod, unlink } from "fs/promises";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import readline from "readline";
|
|
18
|
+
import { migrateAgentConfigCredentialToStore, } from "./agent-config-credential-migration.js";
|
|
19
|
+
/** The only agent-config sources the migration knows how to scan. */
|
|
20
|
+
const ALLOWED_SOURCES = [".mcp.json", ".cursor/mcp.json"];
|
|
21
|
+
/** User-facing usage text for the `credentials` subcommand. */
|
|
22
|
+
export function getCredentialsUsage() {
|
|
23
|
+
return [
|
|
24
|
+
"Usage:",
|
|
25
|
+
" npx -y @bridge_gpt/mcp-server credentials migrate-agent-config \\",
|
|
26
|
+
" [--write-credentials|--no-write-credentials] \\",
|
|
27
|
+
" [--source=.mcp.json|--source=.cursor/mcp.json]",
|
|
28
|
+
"",
|
|
29
|
+
"Migrates a BAPI_API_KEY found in .mcp.json / .cursor/mcp.json into the",
|
|
30
|
+
"user-scoped credential store (~/.config/bridge/credentials.json), so that a",
|
|
31
|
+
"Bash-spawned CLI (e.g. start-tickets) can resolve it. The key value is never",
|
|
32
|
+
"printed.",
|
|
33
|
+
"",
|
|
34
|
+
"Without --write-credentials this is a dry preview: it scans and reports what",
|
|
35
|
+
"it WOULD migrate but writes nothing.",
|
|
36
|
+
"",
|
|
37
|
+
"Flags:",
|
|
38
|
+
" --write-credentials Consent to write the discovered key into the store",
|
|
39
|
+
" --no-write-credentials Dry preview only (default)",
|
|
40
|
+
" --source=<file> Restrict scanning (repeatable):",
|
|
41
|
+
" .mcp.json or .cursor/mcp.json",
|
|
42
|
+
" -h, --help Show this help",
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse argv strictly. Supports the `migrate-agent-config` subcommand,
|
|
47
|
+
* `--write-credentials` / `--no-write-credentials` (last one wins; default
|
|
48
|
+
* false), repeatable `--source=<file>` (validated against the allowed set), and
|
|
49
|
+
* `-h` / `--help`. Unknown subcommands/flags produce a structured error.
|
|
50
|
+
*/
|
|
51
|
+
export function parseCredentialsArgs(argv) {
|
|
52
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
53
|
+
return { status: "help" };
|
|
54
|
+
}
|
|
55
|
+
const positionals = [];
|
|
56
|
+
let writeCredentials = false;
|
|
57
|
+
const sources = [];
|
|
58
|
+
for (const arg of argv) {
|
|
59
|
+
if (arg === "--write-credentials") {
|
|
60
|
+
writeCredentials = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === "--no-write-credentials") {
|
|
64
|
+
writeCredentials = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg.startsWith("--source=")) {
|
|
68
|
+
const value = arg.slice("--source=".length);
|
|
69
|
+
if (!ALLOWED_SOURCES.includes(value)) {
|
|
70
|
+
return {
|
|
71
|
+
status: "error",
|
|
72
|
+
message: `Invalid --source value: '${value}' (allowed: ${ALLOWED_SOURCES.join(", ")}).`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
sources.push(value);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg.startsWith("-")) {
|
|
79
|
+
return { status: "error", message: `Unknown flag: ${arg}` };
|
|
80
|
+
}
|
|
81
|
+
positionals.push(arg);
|
|
82
|
+
}
|
|
83
|
+
if (positionals.length === 0) {
|
|
84
|
+
return {
|
|
85
|
+
status: "error",
|
|
86
|
+
message: "Missing subcommand. Expected: migrate-agent-config.",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (positionals.length > 1) {
|
|
90
|
+
return {
|
|
91
|
+
status: "error",
|
|
92
|
+
message: `Unexpected extra argument: '${positionals[1]}'.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (positionals[0] !== "migrate-agent-config") {
|
|
96
|
+
return {
|
|
97
|
+
status: "error",
|
|
98
|
+
message: `Unknown subcommand: '${positionals[0]}'. Expected: migrate-agent-config.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
status: "ok",
|
|
103
|
+
subcommand: "migrate-agent-config",
|
|
104
|
+
writeCredentials,
|
|
105
|
+
sources,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* TTY-gated interactive chooser for conflicting candidate values. Returns the
|
|
110
|
+
* chosen index, or null to abort. Only ever displays each candidate's
|
|
111
|
+
* `serverName`/`filePath` — never the secret value.
|
|
112
|
+
*/
|
|
113
|
+
function promptChoiceViaReadline(candidates) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
116
|
+
process.stderr.write("Multiple, conflicting BAPI_API_KEY values were found. Choose a source:\n");
|
|
117
|
+
candidates.forEach((c, i) => {
|
|
118
|
+
process.stderr.write(` [${i}] ${c.serverName} in ${c.filePath}\n`);
|
|
119
|
+
});
|
|
120
|
+
rl.question("Enter the number to migrate (or blank to abort): ", (answer) => {
|
|
121
|
+
rl.close();
|
|
122
|
+
const trimmed = answer.trim();
|
|
123
|
+
if (trimmed.length === 0) {
|
|
124
|
+
resolve(null);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const index = Number.parseInt(trimmed, 10);
|
|
128
|
+
if (Number.isInteger(index) && index >= 0 && index < candidates.length) {
|
|
129
|
+
resolve(index);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
resolve(null);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build default CLI deps from the live process: env, cwd, platform, homedir, and
|
|
138
|
+
* `fs/promises` I/O primitives. `promptChoice` is only wired when stdin is a TTY
|
|
139
|
+
* (otherwise undefined, so a conflict refuses non-interactively). `log` and
|
|
140
|
+
* `errorLog` go to stdout/stderr respectively.
|
|
141
|
+
*/
|
|
142
|
+
export function createDefaultCredentialsDeps(writeCredentials) {
|
|
143
|
+
return {
|
|
144
|
+
env: process.env,
|
|
145
|
+
cwd: process.cwd(),
|
|
146
|
+
platform: process.platform,
|
|
147
|
+
homedir: os.homedir,
|
|
148
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
149
|
+
mkdir: (p, o) => mkdir(p, o),
|
|
150
|
+
writeFile: (p, d, o) => writeFile(p, d, o),
|
|
151
|
+
rename: (a, b) => rename(a, b),
|
|
152
|
+
chmod: (p, m) => chmod(p, m),
|
|
153
|
+
unlink: (p) => unlink(p),
|
|
154
|
+
writeCredentials,
|
|
155
|
+
promptChoice: process.stdin.isTTY ? promptChoiceViaReadline : undefined,
|
|
156
|
+
log: (m) => console.log(m),
|
|
157
|
+
errorLog: (m) => console.error(m),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* CLI entry for the `credentials` subcommand. Returns a process exit code. Help
|
|
162
|
+
* returns 0; parser errors return 1. For `migrate-agent-config` it runs the
|
|
163
|
+
* consent-gated migration:
|
|
164
|
+
* - success → confirmation (secret-free), return 0.
|
|
165
|
+
* - `consent-required` → a successful no-op preview: print the exact re-run
|
|
166
|
+
* command WITH `--write-credentials` plus candidate sources, return 0.
|
|
167
|
+
* - any other failure → errorLog the secret-free message, return 1.
|
|
168
|
+
* The migrated key value is never printed anywhere.
|
|
169
|
+
*/
|
|
170
|
+
export async function runCredentialsCli(argv, overrides) {
|
|
171
|
+
const parse = overrides?.parse ?? parseCredentialsArgs;
|
|
172
|
+
const parsed = parse(argv);
|
|
173
|
+
// Use overridden log/errorLog if provided so help/error paths are testable too.
|
|
174
|
+
const log = overrides?.log ?? ((m) => console.log(m));
|
|
175
|
+
const errorLog = overrides?.errorLog ?? ((m) => console.error(m));
|
|
176
|
+
if (parsed.status === "help") {
|
|
177
|
+
log(getCredentialsUsage());
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
if (parsed.status === "error") {
|
|
181
|
+
errorLog(`Error: ${parsed.message}`);
|
|
182
|
+
errorLog("");
|
|
183
|
+
errorLog(getCredentialsUsage());
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
// Build deps: defaults seeded with the parsed consent flag, then any overrides.
|
|
187
|
+
const baseDeps = createDefaultCredentialsDeps(parsed.writeCredentials);
|
|
188
|
+
const deps = { ...baseDeps, ...overrides };
|
|
189
|
+
// Overrides win, but the parsed consent flag is authoritative unless a test
|
|
190
|
+
// explicitly overrode writeCredentials.
|
|
191
|
+
if (overrides?.writeCredentials === undefined) {
|
|
192
|
+
deps.writeCredentials = parsed.writeCredentials;
|
|
193
|
+
}
|
|
194
|
+
// Thread the parsed `--source` restriction into the migration so the flag is
|
|
195
|
+
// honored (not a no-op). Empty → scan both. Overrides win for tests.
|
|
196
|
+
if (overrides?.sources === undefined) {
|
|
197
|
+
deps.sources = parsed.sources;
|
|
198
|
+
}
|
|
199
|
+
const result = await migrateAgentConfigCredentialToStore(deps);
|
|
200
|
+
if (result.ok) {
|
|
201
|
+
deps.log(`Stored routing credential for ${result.target} at ${result.path} ` +
|
|
202
|
+
`(migrated from ${result.sourceServerName} in ${result.sourceFilePath}).`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
if (result.kind === "consent-required") {
|
|
206
|
+
// A successful no-op preview — NOT an error.
|
|
207
|
+
deps.log(result.message);
|
|
208
|
+
deps.log("");
|
|
209
|
+
deps.log("To migrate it, re-run with --write-credentials:");
|
|
210
|
+
deps.log(" npx -y @bridge_gpt/mcp-server credentials migrate-agent-config --write-credentials");
|
|
211
|
+
if (result.candidates && result.candidates.length > 0) {
|
|
212
|
+
deps.log("");
|
|
213
|
+
deps.log("Discovered source(s):");
|
|
214
|
+
for (const candidate of result.candidates) {
|
|
215
|
+
deps.log(` - ${candidate.serverName} in ${candidate.filePath}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
// Any other failure is a real error (secret-free message).
|
|
221
|
+
deps.errorLog(`Error: ${result.message}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
@@ -92,12 +92,72 @@ export const DecisionPageLabelsSchema = z.object({
|
|
|
92
92
|
.optional()
|
|
93
93
|
.describe('Overrides the confirmed-improvements <h2> (default "Confirmed Improvements").'),
|
|
94
94
|
});
|
|
95
|
+
// A single non-functional requirement captured during pre-ticket planning.
|
|
96
|
+
// `status` records how settled the requirement is; `implication` forces the
|
|
97
|
+
// caller to state what the requirement changes about the implementation so the
|
|
98
|
+
// page never accumulates boilerplate. These render read-only — open NFRs that
|
|
99
|
+
// need a human choice belong in `actionable_items`, not here.
|
|
100
|
+
export const SystemGoalNfrSchema = z.object({
|
|
101
|
+
category: z
|
|
102
|
+
.string()
|
|
103
|
+
.min(1)
|
|
104
|
+
.describe("Canonical NFR category, e.g. security/privacy, performance/latency, reliability/failure-modes, observability/auditability, accessibility/UX, data-integrity/migration, compatibility, operability/config, compliance/SOC2, rollout/reversibility."),
|
|
105
|
+
requirement: z.string().min(1).describe("The non-functional requirement itself."),
|
|
106
|
+
implication: z
|
|
107
|
+
.string()
|
|
108
|
+
.min(1)
|
|
109
|
+
.describe("What this requirement changes about the implementation. Required — drop the NFR rather than emit boilerplate without an implication."),
|
|
110
|
+
status: z
|
|
111
|
+
.enum(["confirmed", "assumed", "open"])
|
|
112
|
+
.describe("confirmed = explicitly stated or observable in code; assumed = low-risk and reversible default; open = unresolved (also surface as an actionable_items card)."),
|
|
113
|
+
});
|
|
114
|
+
// Read-only system-goals panel for the pre_ticket_planning artifact. Captures the
|
|
115
|
+
// business goal, the desired end-state, how the system must behave to complete its
|
|
116
|
+
// task, and the classified NFR list. Display-only: it states what is settled; it
|
|
117
|
+
// does not collect input.
|
|
118
|
+
export const SystemGoalsSchema = z.object({
|
|
119
|
+
business_goal: z.string().min(1).describe("The business goal this work serves."),
|
|
120
|
+
desired_end_state: z.string().min(1).describe("The end-state the system should reach."),
|
|
121
|
+
system_behavior: z
|
|
122
|
+
.string()
|
|
123
|
+
.min(1)
|
|
124
|
+
.describe("How the system must behave / complete its task (quality attributes in prose)."),
|
|
125
|
+
nfrs: z.array(SystemGoalNfrSchema).optional().default([]),
|
|
126
|
+
});
|
|
127
|
+
// Read-only recommended implementation order for epic-planning surfaces. Hard
|
|
128
|
+
// prerequisites (`depends_on`) are modelled separately from soft sequencing
|
|
129
|
+
// (`recommended_after`) per the brainstorm; neither becomes a Jira link in this
|
|
130
|
+
// pass — order is delivered into the epic (comment/description) downstream.
|
|
131
|
+
export const ImplementationOrderItemSchema = z.object({
|
|
132
|
+
title: z.string().min(1).describe("Short title of the slice / child ticket."),
|
|
133
|
+
depends_on: z
|
|
134
|
+
.array(z.string().min(1))
|
|
135
|
+
.optional()
|
|
136
|
+
.default([])
|
|
137
|
+
.describe("Hard prerequisites (titles or keys) that must land first."),
|
|
138
|
+
recommended_after: z
|
|
139
|
+
.array(z.string().min(1))
|
|
140
|
+
.optional()
|
|
141
|
+
.default([])
|
|
142
|
+
.describe("Soft sequencing preferences — not hard blockers."),
|
|
143
|
+
rationale: z.string().min(1).describe("Why this slice sits at this point in the order."),
|
|
144
|
+
});
|
|
95
145
|
// Raw input shape for the `generate_decision_page` tool registration.
|
|
96
146
|
// MCP's registerTool expects a shape object (which the SDK wraps in z.object),
|
|
97
147
|
// not a pre-built z.object. Exporting the shape lets index.ts and the schema
|
|
98
148
|
// share a single source of truth for the input contract.
|
|
99
149
|
export const DecisionPageInputShape = {
|
|
100
150
|
ticket_key: z.string().describe("Jira ticket key, e.g. BAPI-123"),
|
|
151
|
+
artifact_type: z
|
|
152
|
+
.enum(["review_decisions", "pre_ticket_planning"])
|
|
153
|
+
.optional()
|
|
154
|
+
.default("review_decisions")
|
|
155
|
+
.describe('Which flavor of page to render. "review_decisions" (default) is the ticket-review decision-capture page and is unaffected by the planning fields. "pre_ticket_planning" additionally renders the read-only system_goals and implementation_order sections for pre-ticket epic/task framing.'),
|
|
156
|
+
system_goals: SystemGoalsSchema.optional().describe("pre_ticket_planning only: read-only business goal, desired end-state, system behavior, and classified NFRs. Unresolved (open) NFRs should ALSO be passed as actionable_items so the human can decide them."),
|
|
157
|
+
implementation_order: z
|
|
158
|
+
.array(ImplementationOrderItemSchema)
|
|
159
|
+
.optional()
|
|
160
|
+
.describe("pre_ticket_planning epic surfaces only: read-only recommended implementation order (hard depends_on vs soft recommended_after). No Jira links are created from this."),
|
|
101
161
|
output_subdir: z
|
|
102
162
|
.string()
|
|
103
163
|
.optional()
|
|
@@ -31,6 +31,7 @@ const COPY_SUCCESS_LABEL = "Copied!";
|
|
|
31
31
|
const COPY_FALLBACK_PROMPT_LABEL = "Auto-copy unavailable. Press Ctrl+C / Cmd+C to copy.";
|
|
32
32
|
const PAGE_INTRO_ASK_COPY = "Have a question about an item? Choose 'Ask about this' for that card; we'll discuss before the changes are made.";
|
|
33
33
|
const PAGE_INTRO_NO_DECISIONS = "All suggestions were confirmed as improvements. No decisions are needed from you.";
|
|
34
|
+
const PAGE_INTRO_PLANNING_NO_DECISIONS = "No open non-functional requirements need a decision. The goals and NFRs above are for your reference.";
|
|
34
35
|
// Raw (unescaped) default label constants. They reproduce the legacy review copy
|
|
35
36
|
// exactly so omitting `labels` yields byte-identical output. Escaping happens at
|
|
36
37
|
// each render site, so these must stay raw to avoid double-escaping.
|
|
@@ -62,15 +63,24 @@ const DEFAULT_ASSETS = { faviconBase64: "", logoBase64: "", fontsRelPath: "" };
|
|
|
62
63
|
export function generateDecisionPageHtml(data, assets = DEFAULT_ASSETS) {
|
|
63
64
|
const { ticket_key, actionable_items, clear_improvements } = data;
|
|
64
65
|
const hasDecisions = actionable_items.length > 0;
|
|
66
|
+
const isPlanning = data.artifact_type === "pre_ticket_planning";
|
|
67
|
+
const needsForm = hasDecisions || (isPlanning && (data.system_goals?.nfrs?.length ?? 0) > 0);
|
|
65
68
|
const effectiveLabels = resolveDecisionPageLabels(data.labels);
|
|
66
69
|
const faviconLink = assets.faviconBase64
|
|
67
70
|
? `<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,${assets.faviconBase64}">`
|
|
68
71
|
: "";
|
|
69
72
|
const fontFaces = assets.fontsRelPath ? renderFontFaces(assets.fontsRelPath) : "";
|
|
70
73
|
// The has-decisions intro is an overridable label; the no-decisions copy is a
|
|
71
|
-
// fixed constant
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
+
// fixed constant. For review_decisions pages the MCP handler short-circuits
|
|
75
|
+
// before rendering when there are no decisions, so that branch is reachable
|
|
76
|
+
// only from direct callers/tests. For pre_ticket_planning pages it IS reachable
|
|
77
|
+
// from the handler — a goals-only page (system_goals present, no actionable
|
|
78
|
+
// items) renders here and shows PAGE_INTRO_PLANNING_NO_DECISIONS.
|
|
79
|
+
const pageIntro = hasDecisions
|
|
80
|
+
? effectiveLabels.intro
|
|
81
|
+
: isPlanning
|
|
82
|
+
? PAGE_INTRO_PLANNING_NO_DECISIONS
|
|
83
|
+
: PAGE_INTRO_NO_DECISIONS;
|
|
74
84
|
return `<!DOCTYPE html>
|
|
75
85
|
<html lang="en">
|
|
76
86
|
<head>
|
|
@@ -413,6 +423,50 @@ ${fontFaces}
|
|
|
413
423
|
margin-top: 0.25rem;
|
|
414
424
|
}
|
|
415
425
|
|
|
426
|
+
/* Pre-ticket planning: read-only system goals + implementation order */
|
|
427
|
+
.planning-section { margin-bottom: 1rem; }
|
|
428
|
+
.goal-row { margin-bottom: 0.75rem; }
|
|
429
|
+
.goal-label {
|
|
430
|
+
font-size: 0.875rem;
|
|
431
|
+
font-weight: 600;
|
|
432
|
+
color: var(--secondary-color);
|
|
433
|
+
margin-bottom: 0.25rem;
|
|
434
|
+
}
|
|
435
|
+
.goal-body {
|
|
436
|
+
font-size: 0.95rem;
|
|
437
|
+
color: var(--text-color);
|
|
438
|
+
white-space: pre-wrap;
|
|
439
|
+
}
|
|
440
|
+
.nfr-list, .order-list {
|
|
441
|
+
list-style: none;
|
|
442
|
+
padding: 0;
|
|
443
|
+
margin-top: 0.5rem;
|
|
444
|
+
}
|
|
445
|
+
.nfr-list li, .order-list li {
|
|
446
|
+
padding: 0.75rem 0;
|
|
447
|
+
border-bottom: 1px solid var(--border-color);
|
|
448
|
+
}
|
|
449
|
+
.nfr-list li:last-child, .order-list li:last-child { border-bottom: none; }
|
|
450
|
+
.nfr-category { font-weight: 600; }
|
|
451
|
+
.nfr-implication, .order-meta {
|
|
452
|
+
font-size: 0.9rem;
|
|
453
|
+
color: var(--secondary-color);
|
|
454
|
+
margin-top: 0.25rem;
|
|
455
|
+
}
|
|
456
|
+
.order-title { font-weight: 600; }
|
|
457
|
+
.status-tag {
|
|
458
|
+
display: inline-block;
|
|
459
|
+
padding: 0.1rem 0.5rem;
|
|
460
|
+
border-radius: 3px;
|
|
461
|
+
font-size: 0.75rem;
|
|
462
|
+
font-weight: 600;
|
|
463
|
+
text-transform: uppercase;
|
|
464
|
+
margin-left: 0.5rem;
|
|
465
|
+
}
|
|
466
|
+
.status-confirmed { background: #dcfce7; color: #166534; }
|
|
467
|
+
.status-assumed { background: #fef3c7; color: #92400e; }
|
|
468
|
+
.status-open { background: #fef2f2; color: #991b1b; }
|
|
469
|
+
|
|
416
470
|
/* No decisions state */
|
|
417
471
|
.no-decisions-msg {
|
|
418
472
|
text-align: center;
|
|
@@ -421,6 +475,32 @@ ${fontFaces}
|
|
|
421
475
|
font-size: 1.2rem;
|
|
422
476
|
}
|
|
423
477
|
|
|
478
|
+
/* Focus style applied globally across all textareas */
|
|
479
|
+
textarea:focus {
|
|
480
|
+
outline: none;
|
|
481
|
+
border-color: var(--primary-color);
|
|
482
|
+
box-shadow: 0 0 0 3px rgba(226, 98, 75, 0.2);
|
|
483
|
+
}
|
|
484
|
+
.nfr-feedback .comment-area {
|
|
485
|
+
display: block !important;
|
|
486
|
+
overflow: hidden;
|
|
487
|
+
transition: max-height 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
488
|
+
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
489
|
+
margin-top 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
490
|
+
}
|
|
491
|
+
.nfr-feedback .comment-area.hidden {
|
|
492
|
+
max-height: 0;
|
|
493
|
+
opacity: 0;
|
|
494
|
+
margin-top: 0;
|
|
495
|
+
pointer-events: none;
|
|
496
|
+
}
|
|
497
|
+
.nfr-feedback .radio-group {
|
|
498
|
+
display: flex;
|
|
499
|
+
flex-direction: row;
|
|
500
|
+
gap: 1.5rem;
|
|
501
|
+
margin-top: 0.75rem;
|
|
502
|
+
}
|
|
503
|
+
|
|
424
504
|
@media (max-width: 768px) {
|
|
425
505
|
.main-content { padding: 0 1rem; margin: 1.5rem auto; }
|
|
426
506
|
.container { padding: 1.5rem; }
|
|
@@ -428,6 +508,10 @@ ${fontFaces}
|
|
|
428
508
|
align-items: stretch;
|
|
429
509
|
flex-direction: column;
|
|
430
510
|
}
|
|
511
|
+
.nfr-feedback .radio-group {
|
|
512
|
+
flex-direction: column;
|
|
513
|
+
gap: 0.75rem;
|
|
514
|
+
}
|
|
431
515
|
}
|
|
432
516
|
</style>
|
|
433
517
|
</head>
|
|
@@ -438,13 +522,15 @@ ${renderHeader(assets)}
|
|
|
438
522
|
<h1>${escapeHtml(effectiveLabels.title)}: ${escapeHtml(ticket_key)}</h1>
|
|
439
523
|
<p class="page-intro">${escapeHtml(pageIntro)}</p>
|
|
440
524
|
|
|
441
|
-
${
|
|
525
|
+
${renderSystemGoals(data.system_goals, isPlanning)}
|
|
526
|
+
${renderImplementationOrder(data.implementation_order)}
|
|
527
|
+
${needsForm ? renderForm(data, effectiveLabels, hasDecisions) : renderNoDecisions(isPlanning)}
|
|
442
528
|
|
|
443
529
|
${renderImprovements(clear_improvements, effectiveLabels)}
|
|
444
530
|
|
|
445
531
|
</div>
|
|
446
532
|
</main>
|
|
447
|
-
${
|
|
533
|
+
${needsForm ? renderScript(data, isPlanning) : ""}
|
|
448
534
|
</body>
|
|
449
535
|
</html>`;
|
|
450
536
|
}
|
|
@@ -489,16 +575,124 @@ function renderHeader(assets) {
|
|
|
489
575
|
</div>
|
|
490
576
|
</header>`;
|
|
491
577
|
}
|
|
492
|
-
function renderNoDecisions() {
|
|
578
|
+
function renderNoDecisions(isPlanning = false) {
|
|
579
|
+
const message = isPlanning
|
|
580
|
+
? "No open decisions. The goals and non-functional requirements above are for your reference."
|
|
581
|
+
: "No decisions needed. All suggestions were confirmed as improvements.";
|
|
493
582
|
return ` <div class="no-decisions-msg">
|
|
494
|
-
<p
|
|
583
|
+
<p>${escapeHtml(message)}</p>
|
|
495
584
|
</div>`;
|
|
496
585
|
}
|
|
497
|
-
|
|
586
|
+
// Read-only system-goals panel for the pre_ticket_planning artifact. Returns ""
|
|
587
|
+
// when no goals are supplied so the review_decisions page is byte-for-byte
|
|
588
|
+
// unchanged. Every model-supplied string is escaped via escapeHtml().
|
|
589
|
+
// When isPlanning is true, each NFR renders an interactive stance control
|
|
590
|
+
// (Agreed / Ask about this / Disagree) with a conditional comment textarea.
|
|
591
|
+
function renderSystemGoals(goals, isPlanning = false) {
|
|
592
|
+
if (!goals)
|
|
593
|
+
return "";
|
|
594
|
+
const nfrs = goals.nfrs ?? [];
|
|
595
|
+
let nfrHtml = "";
|
|
596
|
+
if (nfrs.length > 0) {
|
|
597
|
+
let items = "";
|
|
598
|
+
for (const nfr of nfrs) {
|
|
599
|
+
const statusClass = nfr.status === "confirmed"
|
|
600
|
+
? "status-confirmed"
|
|
601
|
+
: nfr.status === "assumed"
|
|
602
|
+
? "status-assumed"
|
|
603
|
+
: "status-open";
|
|
604
|
+
// nfrId is used as data-nfr-id (becomes the JSON output key) and aria-label.
|
|
605
|
+
// nfrHtmlId is a whitespace-free variant used for id/name/for attributes —
|
|
606
|
+
// HTML5 forbids spaces in id values.
|
|
607
|
+
const nfrId = escapeHtml(nfr.category);
|
|
608
|
+
const nfrHtmlId = escapeHtml(nfr.category.replace(/\s+/g, "-"));
|
|
609
|
+
const radioName = `nfr-stance-${nfrHtmlId}`;
|
|
610
|
+
const textareaId = `nfr-comment-${nfrHtmlId}`;
|
|
611
|
+
const stanceControls = isPlanning ? `
|
|
612
|
+
<div class="nfr-feedback" data-nfr-id="${nfrId}">
|
|
613
|
+
<div class="radio-group" role="radiogroup" aria-label="Stance on ${nfrId}">
|
|
614
|
+
<div class="radio-option">
|
|
615
|
+
<input type="radio" id="${radioName}-agreed" name="${radioName}" value="agreed" checked data-testid="nfr-stance-radio">
|
|
616
|
+
<label for="${radioName}-agreed">Agreed</label>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="radio-option">
|
|
619
|
+
<input type="radio" id="${radioName}-ask" name="${radioName}" value="ask" data-testid="nfr-stance-radio">
|
|
620
|
+
<label for="${radioName}-ask">Ask about this</label>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="radio-option">
|
|
623
|
+
<input type="radio" id="${radioName}-disagree" name="${radioName}" value="disagree" data-testid="nfr-stance-radio">
|
|
624
|
+
<label for="${radioName}-disagree">Disagree</label>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
<div class="comment-area hidden">
|
|
628
|
+
<label for="${textareaId}">Comment</label>
|
|
629
|
+
<textarea id="${textareaId}" name="${textareaId}" placeholder="Explain your question or concern..." data-testid="nfr-comment"></textarea>
|
|
630
|
+
</div>
|
|
631
|
+
</div>` : "";
|
|
632
|
+
items += `
|
|
633
|
+
<li data-testid="system-goal-nfr" data-status="${escapeHtml(nfr.status)}">
|
|
634
|
+
<span class="nfr-category">${escapeHtml(nfr.category)}</span><span class="status-tag ${statusClass}">${escapeHtml(nfr.status)}</span>
|
|
635
|
+
<div class="goal-body">${escapeHtml(nfr.requirement)}</div>
|
|
636
|
+
<div class="nfr-implication">Implication: ${escapeHtml(nfr.implication)}</div>${stanceControls}
|
|
637
|
+
</li>`;
|
|
638
|
+
}
|
|
639
|
+
nfrHtml = `
|
|
640
|
+
<div class="goal-label">Non-functional requirements</div>
|
|
641
|
+
<ul class="nfr-list">${items}
|
|
642
|
+
</ul>`;
|
|
643
|
+
}
|
|
644
|
+
return ` <section class="planning-section" data-testid="system-goals">
|
|
645
|
+
<h2>System Goals & Non-Functional Requirements</h2>
|
|
646
|
+
<div class="goal-row">
|
|
647
|
+
<div class="goal-label">Business goal</div>
|
|
648
|
+
<div class="goal-body" data-testid="system-goal-business">${escapeHtml(goals.business_goal)}</div>
|
|
649
|
+
</div>
|
|
650
|
+
<div class="goal-row">
|
|
651
|
+
<div class="goal-label">Desired end-state</div>
|
|
652
|
+
<div class="goal-body" data-testid="system-goal-end-state">${escapeHtml(goals.desired_end_state)}</div>
|
|
653
|
+
</div>
|
|
654
|
+
<div class="goal-row">
|
|
655
|
+
<div class="goal-label">System behavior</div>
|
|
656
|
+
<div class="goal-body" data-testid="system-goal-behavior">${escapeHtml(goals.system_behavior)}</div>
|
|
657
|
+
</div>${nfrHtml}
|
|
658
|
+
</section>`;
|
|
659
|
+
}
|
|
660
|
+
// Read-only recommended implementation order (epic surfaces). Returns "" when the
|
|
661
|
+
// list is absent or empty. No interactive controls — ordering is delivered into
|
|
662
|
+
// the epic downstream, not edited here.
|
|
663
|
+
function renderImplementationOrder(order) {
|
|
664
|
+
if (!order || order.length === 0)
|
|
665
|
+
return "";
|
|
666
|
+
let items = "";
|
|
667
|
+
for (let i = 0; i < order.length; i++) {
|
|
668
|
+
const item = order[i];
|
|
669
|
+
const dependsOn = item.depends_on ?? [];
|
|
670
|
+
const recommendedAfter = item.recommended_after ?? [];
|
|
671
|
+
const dependsLine = dependsOn.length > 0
|
|
672
|
+
? `<div class="order-meta" data-testid="order-depends-on">Depends on: ${escapeHtml(dependsOn.join(", "))}</div>`
|
|
673
|
+
: "";
|
|
674
|
+
const afterLine = recommendedAfter.length > 0
|
|
675
|
+
? `<div class="order-meta" data-testid="order-recommended-after">Recommended after: ${escapeHtml(recommendedAfter.join(", "))}</div>`
|
|
676
|
+
: "";
|
|
677
|
+
items += `
|
|
678
|
+
<li data-testid="implementation-order-item">
|
|
679
|
+
<span class="order-title">${i + 1}. ${escapeHtml(item.title)}</span>
|
|
680
|
+
<div class="order-meta">${escapeHtml(item.rationale)}</div>
|
|
681
|
+
${dependsLine}${afterLine}
|
|
682
|
+
</li>`;
|
|
683
|
+
}
|
|
684
|
+
return ` <section class="planning-section" data-testid="implementation-order">
|
|
685
|
+
<h2>Recommended Implementation Order</h2>
|
|
686
|
+
<p>Recommended sequence only — no Jira dependency links are created from this.</p>
|
|
687
|
+
<ul class="order-list">${items}
|
|
688
|
+
</ul>
|
|
689
|
+
</section>`;
|
|
690
|
+
}
|
|
691
|
+
function renderForm(data, labels, hasDecisions = true) {
|
|
498
692
|
const { actionable_items } = data;
|
|
499
693
|
let html = ` <div id="form-container">
|
|
500
694
|
<form id="decision-form">`;
|
|
501
|
-
if (actionable_items.length > 0) {
|
|
695
|
+
if (hasDecisions && actionable_items.length > 0) {
|
|
502
696
|
html += `
|
|
503
697
|
<h2>${escapeHtml(labels.section_heading)}</h2>`;
|
|
504
698
|
for (const item of actionable_items) {
|
|
@@ -638,7 +832,7 @@ function renderImprovements(improvements, labels) {
|
|
|
638
832
|
// ---------------------------------------------------------------------------
|
|
639
833
|
// Embedded script
|
|
640
834
|
// ---------------------------------------------------------------------------
|
|
641
|
-
function renderScript(data) {
|
|
835
|
+
function renderScript(data, isPlanning = false) {
|
|
642
836
|
return ` <script>
|
|
643
837
|
(function() {
|
|
644
838
|
var submitBtn;
|
|
@@ -654,6 +848,29 @@ function renderScript(data) {
|
|
|
654
848
|
postSubmitContainer = document.getElementById("post-submit-container");
|
|
655
849
|
jsonOutput = document.getElementById("json-output");
|
|
656
850
|
|
|
851
|
+
${isPlanning ? `// NFR stance radio disclosure (planning mode only)
|
|
852
|
+
var nfrContainers = document.querySelectorAll(".nfr-feedback");
|
|
853
|
+
nfrContainers.forEach(function(container) {
|
|
854
|
+
var radios = container.querySelectorAll('input[type="radio"]');
|
|
855
|
+
radios.forEach(function(radio) {
|
|
856
|
+
radio.addEventListener("change", function() {
|
|
857
|
+
var commentArea = container.querySelector(".comment-area");
|
|
858
|
+
var textarea = commentArea ? commentArea.querySelector("textarea") : null;
|
|
859
|
+
if (radio.value === "ask" || radio.value === "disagree") {
|
|
860
|
+
commentArea.classList.remove("hidden");
|
|
861
|
+
} else {
|
|
862
|
+
commentArea.classList.add("hidden");
|
|
863
|
+
if (textarea) {
|
|
864
|
+
textarea.value = "";
|
|
865
|
+
textarea.classList.remove("validation-error");
|
|
866
|
+
var existingMsg = commentArea.querySelector(".validation-msg");
|
|
867
|
+
if (existingMsg) existingMsg.remove();
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
});` : `var nfrContainers = [];`}
|
|
873
|
+
|
|
657
874
|
submitBtn.addEventListener("click", function() {
|
|
658
875
|
var cards = document.querySelectorAll(".card[data-item-id]");
|
|
659
876
|
var valid = true;
|
|
@@ -687,6 +904,27 @@ function renderScript(data) {
|
|
|
687
904
|
}
|
|
688
905
|
});
|
|
689
906
|
|
|
907
|
+
// Validate NFR feedback: ask/disagree stance requires a comment.
|
|
908
|
+
nfrContainers.forEach(function(container) {
|
|
909
|
+
var selected = container.querySelector('input[type="radio"]:checked');
|
|
910
|
+
if (!selected) return;
|
|
911
|
+
if (selected.value === "ask" || selected.value === "disagree") {
|
|
912
|
+
var commentArea = container.querySelector(".comment-area");
|
|
913
|
+
var textarea = commentArea ? commentArea.querySelector("textarea") : null;
|
|
914
|
+
var existingMsg = commentArea ? commentArea.querySelector(".validation-msg") : null;
|
|
915
|
+
if (existingMsg) existingMsg.remove();
|
|
916
|
+
if (textarea) textarea.classList.remove("validation-error");
|
|
917
|
+
if (!textarea || !textarea.value.trim()) {
|
|
918
|
+
if (textarea) textarea.classList.add("validation-error");
|
|
919
|
+
var msg = document.createElement("div");
|
|
920
|
+
msg.className = "validation-msg";
|
|
921
|
+
msg.textContent = "A comment is required.";
|
|
922
|
+
if (commentArea) commentArea.appendChild(msg);
|
|
923
|
+
valid = false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
|
|
690
928
|
if (!valid) return;
|
|
691
929
|
|
|
692
930
|
// Build output JSON
|
|
@@ -714,9 +952,23 @@ function renderScript(data) {
|
|
|
714
952
|
};
|
|
715
953
|
});
|
|
716
954
|
|
|
955
|
+
${isPlanning ? `// Capture NFR feedback stances
|
|
956
|
+
var nfrFeedback = {};
|
|
957
|
+
nfrContainers.forEach(function(container) {
|
|
958
|
+
var nfrId = container.getAttribute("data-nfr-id");
|
|
959
|
+
var selected = container.querySelector('input[type="radio"]:checked');
|
|
960
|
+
var commentArea = container.querySelector(".comment-area");
|
|
961
|
+
var textarea = commentArea ? commentArea.querySelector("textarea") : null;
|
|
962
|
+
nfrFeedback[nfrId] = {
|
|
963
|
+
stance: selected ? selected.value : "agreed",
|
|
964
|
+
comment: textarea ? textarea.value : ""
|
|
965
|
+
};
|
|
966
|
+
});` : ""}
|
|
967
|
+
|
|
717
968
|
var output = {
|
|
718
969
|
ticket_key: ${safeJsonForScript(data.ticket_key)},
|
|
719
970
|
decisions: decisions,
|
|
971
|
+
${isPlanning ? "nfr_feedback: nfrFeedback," : ""}
|
|
720
972
|
general_comment: document.getElementById("general-comment").value
|
|
721
973
|
};
|
|
722
974
|
|
package/build/doctor.js
CHANGED
|
@@ -35,7 +35,11 @@ export function getDoctorUsage() {
|
|
|
35
35
|
"",
|
|
36
36
|
"Checks (for the current OS): the start-tickets preflight prerequisites plus",
|
|
37
37
|
"uv, the selected agent's command, Bridge API credential resolution, and",
|
|
38
|
-
"worktree MCP registration reachability.",
|
|
38
|
+
"worktree MCP registration reachability. Credential resolution reports the",
|
|
39
|
+
"source it would use (env vs. store target bapi:<repo>); it never reads or",
|
|
40
|
+
"prints the key value and never writes the credential store. To persist or",
|
|
41
|
+
"migrate a credential, use /install-bridge or the `credentials` subcommand —",
|
|
42
|
+
"doctor stays strictly read-only.",
|
|
39
43
|
"",
|
|
40
44
|
"Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
|
|
41
45
|
].join("\n");
|