@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.
Files changed (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +468 -59
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. 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 because the MCP handler returns before rendering in that path
72
- // (this branch is only reachable from direct renderer callers/tests).
73
- const pageIntro = hasDecisions ? effectiveLabels.intro : PAGE_INTRO_NO_DECISIONS;
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
- ${hasDecisions ? renderForm(data, effectiveLabels) : renderNoDecisions()}
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
- ${hasDecisions ? renderScript(data) : ""}
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>No decisions needed. All suggestions were confirmed as improvements.</p>
583
+ <p>${escapeHtml(message)}</p>
495
584
  </div>`;
496
585
  }
497
- function renderForm(data, labels) {
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 &amp; 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");