@bridge_gpt/mcp-server 0.1.17 → 0.2.1
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 +334 -196
- package/build/agent-capabilities/cli.js +152 -0
- package/build/agent-capabilities/default-deps.js +45 -0
- package/build/agent-capabilities/probe-context.js +111 -0
- package/build/agent-capabilities/probes.js +278 -0
- package/build/agent-capabilities/reporter.js +50 -0
- package/build/agent-capabilities/runner.js +56 -0
- package/build/agent-capabilities/types.js +10 -0
- package/build/agent-launchers/claude.js +25 -17
- package/build/agent-launchers/cursor.js +65 -0
- package/build/agent-launchers/index.js +23 -8
- package/build/agent-registry.js +68 -0
- package/build/agents.generated.js +1 -1
- package/build/brainstorm-files.js +89 -0
- package/build/bridge-config.js +404 -0
- package/build/chain-orchestrator.js +247 -33
- package/build/command-catalog.js +376 -0
- package/build/commands.generated.js +10 -7
- package/build/credential-materialization.js +128 -0
- package/build/credential-store.js +232 -0
- package/build/decision-page-schema.js +39 -6
- package/build/decision-page-template.js +54 -18
- package/build/doctor.js +18 -2
- package/build/git-ignore-utils.js +63 -0
- package/build/index.js +1707 -557
- package/build/mcp-invoke.js +417 -0
- package/build/mcp-provisioning.js +342 -0
- package/build/mcp-registration-doctor.js +96 -0
- package/build/pipeline-orchestrator.js +9 -1
- package/build/pipelines.generated.js +5 -3
- package/build/schedule-run.js +440 -92
- package/build/schedule-store.js +41 -1
- package/build/scheduled-prompt.js +109 -0
- package/build/scheduler-backends/at-fallback.js +5 -10
- package/build/scheduler-backends/escaping.js +40 -10
- package/build/scheduler-backends/launchd.js +23 -14
- package/build/scheduler-backends/systemd-user.js +32 -19
- package/build/scheduler-backends/task-scheduler.js +8 -13
- package/build/start-tickets-prereqs.js +90 -1
- package/build/start-tickets.js +563 -42
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
- package/pipelines/full-automation.json +3 -1
- package/smoke-test/SMOKE-TEST.md +62 -17
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolver for Tier-1 bridge-api credentials.
|
|
3
|
+
*
|
|
4
|
+
* Resolution order is env-first, then a user-scoped credentials file:
|
|
5
|
+
* 1. `BAPI_API_KEY` in the process environment (overrides everything).
|
|
6
|
+
* 2. `$XDG_CONFIG_HOME/bridge/credentials.json` (or `~/.config/bridge/credentials.json`).
|
|
7
|
+
* 3. `~/.bridge/credentials.json` (only when the primary path is absent).
|
|
8
|
+
*
|
|
9
|
+
* The file is keyed by a logical target `bapi:<repoName>`. This module NEVER
|
|
10
|
+
* creates or initializes credential files, and NEVER places secret values in
|
|
11
|
+
* thrown errors, returned error strings, or stderr warnings.
|
|
12
|
+
*/
|
|
13
|
+
import path from "path";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Path resolution
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/** Primary store path: honors a non-empty `XDG_CONFIG_HOME`, else `~/.config`. */
|
|
18
|
+
export function getPrimaryCredentialStorePath(deps) {
|
|
19
|
+
const xdg = deps.env.XDG_CONFIG_HOME;
|
|
20
|
+
if (xdg && xdg.trim().length > 0) {
|
|
21
|
+
return path.join(xdg, "bridge", "credentials.json");
|
|
22
|
+
}
|
|
23
|
+
return path.join(deps.homedir(), ".config", "bridge", "credentials.json");
|
|
24
|
+
}
|
|
25
|
+
/** Fallback store path: `~/.bridge/credentials.json`. */
|
|
26
|
+
export function getFallbackCredentialStorePath(deps) {
|
|
27
|
+
return path.join(deps.homedir(), ".bridge", "credentials.json");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Choose which store path to read. The primary path is preferred and used
|
|
31
|
+
* whenever it exists. The fallback is only consulted when the primary is
|
|
32
|
+
* *absent* (`ENOENT`); a primary that exists but is unreadable does NOT fall
|
|
33
|
+
* back — the caller surfaces the read error instead.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveCredentialStorePath(deps) {
|
|
36
|
+
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
37
|
+
const fallbackPath = getFallbackCredentialStorePath(deps);
|
|
38
|
+
try {
|
|
39
|
+
await deps.stat(primaryPath);
|
|
40
|
+
return { found: true, path: primaryPath, isPrimary: true };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const code = err && typeof err === "object" ? err.code : undefined;
|
|
44
|
+
if (code !== "ENOENT") {
|
|
45
|
+
// The primary exists but is not statable cleanly — do NOT fall back; let
|
|
46
|
+
// the subsequent read surface the real error against the primary path.
|
|
47
|
+
return { found: true, path: primaryPath, isPrimary: true };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await deps.stat(fallbackPath);
|
|
52
|
+
return { found: true, path: fallbackPath, isPrimary: false };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { found: false, primaryPath, fallbackPath };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Permission warning
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* On POSIX platforms, warn (but continue) when the credentials file is
|
|
63
|
+
* group/world accessible. Skipped entirely on Windows. The warning mentions
|
|
64
|
+
* `0600` but never any secret value.
|
|
65
|
+
*/
|
|
66
|
+
export function warnIfInsecureCredentialFileMode(statResult, platform, filePath, stderr) {
|
|
67
|
+
if (platform === "win32")
|
|
68
|
+
return;
|
|
69
|
+
if ((statResult.mode & 0o077) !== 0) {
|
|
70
|
+
const write = stderr ?? ((m) => process.stderr.write(`${m}\n`));
|
|
71
|
+
write(`Warning: credentials file ${filePath} is group/world-accessible; ` +
|
|
72
|
+
`it should be mode 0600 (run: chmod 600 ${filePath}).`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// JSON parsing / validation
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Parse and structurally validate the credentials JSON. The root must be an
|
|
80
|
+
* object whose values are objects of secret-name -> string. Secret *values* are
|
|
81
|
+
* never echoed into error messages (only logical keys and secret names appear).
|
|
82
|
+
*/
|
|
83
|
+
export function parseCredentialStoreJson(text) {
|
|
84
|
+
let data;
|
|
85
|
+
try {
|
|
86
|
+
data = JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { ok: false, error: "credentials file is not valid JSON" };
|
|
90
|
+
}
|
|
91
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
92
|
+
return { ok: false, error: "credentials file must be a JSON object" };
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of Object.entries(data)) {
|
|
95
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `credentials entry "${key}" must be an object of secret names to strings`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
for (const [secretName, secretValue] of Object.entries(value)) {
|
|
102
|
+
if (typeof secretValue !== "string") {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `credentials entry "${key}" has a non-string value for "${secretName}"`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, value: data };
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Resolution
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/** Collect trimmed, non-empty env values for the requested keys. */
|
|
116
|
+
function collectEnvValues(env, requiredKeys) {
|
|
117
|
+
const values = {};
|
|
118
|
+
const missing = [];
|
|
119
|
+
for (const key of requiredKeys) {
|
|
120
|
+
const raw = (env[key] ?? "").trim();
|
|
121
|
+
if (raw.length > 0) {
|
|
122
|
+
values[key] = raw;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
missing.push(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { values, missing };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a logical credential bundle env-first, then from the user-scoped
|
|
132
|
+
* credentials file. `bundleKey` is the logical store key (e.g. `bapi:<repo>` or
|
|
133
|
+
* `sfcc:<sandbox-id>`); `requiredKeys` is the exact set of secret names that
|
|
134
|
+
* must all resolve.
|
|
135
|
+
*
|
|
136
|
+
* Behavior:
|
|
137
|
+
* - Environment is consulted first. If EVERY required key is present in the
|
|
138
|
+
* environment (trimmed, non-empty), the store is NEVER read and `source` is
|
|
139
|
+
* `"env"`.
|
|
140
|
+
* - Only when one or more keys are missing from the environment is the store
|
|
141
|
+
* read (exactly once). Env values override store values key-by-key.
|
|
142
|
+
* - The result fails unless every required key resolves.
|
|
143
|
+
*
|
|
144
|
+
* Error messages reference only the logical bundle key, the missing secret
|
|
145
|
+
* names, and the expected store path — never a secret value. Never creates
|
|
146
|
+
* files.
|
|
147
|
+
*/
|
|
148
|
+
export async function resolveCredentialBundle(bundleKey, requiredKeys, deps) {
|
|
149
|
+
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
150
|
+
const fromEnv = collectEnvValues(deps.env, requiredKeys);
|
|
151
|
+
if (fromEnv.missing.length === 0) {
|
|
152
|
+
return { ok: true, values: fromEnv.values, source: "env" };
|
|
153
|
+
}
|
|
154
|
+
const resolution = await resolveCredentialStorePath(deps);
|
|
155
|
+
if (!resolution.found) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
kind: "not-found",
|
|
159
|
+
error: `No credentials found for "${bundleKey}". Set ${fromEnv.missing.join(", ")} in the ` +
|
|
160
|
+
`environment, or add them under "${bundleKey}" in ${primaryPath}.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Permission warning is best-effort and must not block resolution.
|
|
164
|
+
try {
|
|
165
|
+
const statResult = await deps.stat(resolution.path);
|
|
166
|
+
warnIfInsecureCredentialFileMode(statResult, deps.platform, resolution.path, deps.stderr);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* stat failure here is non-fatal; the read below surfaces real errors */
|
|
170
|
+
}
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = await deps.readFile(resolution.path);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
kind: "read-error",
|
|
179
|
+
error: `Unable to read credentials file at ${resolution.path}.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const parsed = parseCredentialStoreJson(raw);
|
|
183
|
+
if (!parsed.ok) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
kind: "parse-error",
|
|
187
|
+
error: `Invalid credentials file at ${resolution.path}: ${parsed.error}.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const entry = parsed.value[bundleKey] ?? {};
|
|
191
|
+
const values = { ...fromEnv.values };
|
|
192
|
+
const stillMissing = [];
|
|
193
|
+
for (const key of fromEnv.missing) {
|
|
194
|
+
// Trim store values exactly like the env path (collectEnvValues), so a
|
|
195
|
+
// credential pasted into credentials.json with a trailing newline/space is
|
|
196
|
+
// never sent verbatim in an auth header. Assign the TRIMMED value.
|
|
197
|
+
const storeValue = typeof entry[key] === "string" ? entry[key].trim() : "";
|
|
198
|
+
if (storeValue.length > 0) {
|
|
199
|
+
values[key] = storeValue;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
stillMissing.push(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (stillMissing.length > 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
kind: "missing-key",
|
|
209
|
+
error: `No usable value(s) for ${stillMissing.join(", ")} in "${bundleKey}". Add them under ` +
|
|
210
|
+
`"${bundleKey}" in ${primaryPath}, or set them in the environment.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, values, source: "file" };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Resolve the `BAPI_API_KEY` for `bapi:<repoName>`. Delegates to the generic
|
|
217
|
+
* {@link resolveCredentialBundle} with bundle key `bapi:${repoName}` and the
|
|
218
|
+
* single required key `BAPI_API_KEY`, preserving the historical public return
|
|
219
|
+
* shape (`{ apiKey, source }`) relied on by `mcp-invoke` and `index.ts`. Env
|
|
220
|
+
* wins (trimmed, non-empty); otherwise the credentials file is read. Never
|
|
221
|
+
* leaks secret values; never creates files.
|
|
222
|
+
*/
|
|
223
|
+
export async function resolveBapiCredentials(repoName, deps) {
|
|
224
|
+
const result = await resolveCredentialBundle(`bapi:${repoName}`, ["BAPI_API_KEY"], deps);
|
|
225
|
+
if (!result.ok) {
|
|
226
|
+
return { ok: false, kind: result.kind, error: result.error };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
credentials: { apiKey: result.values.BAPI_API_KEY, source: result.source },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -19,8 +19,8 @@ export const ActionableItemSchema = z
|
|
|
19
19
|
question: z.string().min(1),
|
|
20
20
|
original_question: z
|
|
21
21
|
.string()
|
|
22
|
-
.
|
|
23
|
-
.describe("
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Optional display-only field: the clarifying question or critique point as originally raised; soft cap ~30 words. Omit it (or pass an empty string) for non-review callers — the renderer omits the section when it is absent or blank."),
|
|
24
24
|
why_it_matters: z
|
|
25
25
|
.string()
|
|
26
26
|
.min(1)
|
|
@@ -31,12 +31,12 @@ export const ActionableItemSchema = z
|
|
|
31
31
|
.describe("Why the recommended branch is the best choice; soft cap ~60 words."),
|
|
32
32
|
codebase_evidence: z
|
|
33
33
|
.string()
|
|
34
|
-
.
|
|
35
|
-
.describe("
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Optional display-only field: combined Assessment paragraph and Codebase Evidence bullet list. Rendered as escaped plain text inside a closed-by-default <details> block, which is omitted when this field is absent or blank."),
|
|
36
36
|
source: z
|
|
37
37
|
.string()
|
|
38
|
-
.
|
|
39
|
-
.describe("
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Optional source reference from the combined review-and-resolution doc, e.g. 'Clarifying Q3 (prior round, weak concurrence)'. When absent the rendered card emits data-source=\"\"."),
|
|
40
40
|
recommendation_index: z
|
|
41
41
|
.number()
|
|
42
42
|
.int()
|
|
@@ -69,12 +69,45 @@ export const ActionableItemSchema = z
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
});
|
|
72
|
+
// Optional presentation labels that override the page's review-flavored copy.
|
|
73
|
+
// Every field is optional and may be an explicitly empty string (no .min(1)) so
|
|
74
|
+
// a caller can intentionally render an empty heading/title segment. The renderer
|
|
75
|
+
// merges these with raw default constants using nullish coalescing, so an
|
|
76
|
+
// explicit "" is preserved rather than falling back to a default.
|
|
77
|
+
export const DecisionPageLabelsSchema = z.object({
|
|
78
|
+
title: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Overrides the page <title>/<h1> lead text (default "Review Decisions").'),
|
|
82
|
+
intro: z
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Overrides the actionable-page intro copy shown when there are decisions."),
|
|
86
|
+
section_heading: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('Overrides the decision cards <h2> (default "Review Decisions").'),
|
|
90
|
+
improvements_heading: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('Overrides the confirmed-improvements <h2> (default "Confirmed Improvements").'),
|
|
94
|
+
});
|
|
72
95
|
// Raw input shape for the `generate_decision_page` tool registration.
|
|
73
96
|
// MCP's registerTool expects a shape object (which the SDK wraps in z.object),
|
|
74
97
|
// not a pre-built z.object. Exporting the shape lets index.ts and the schema
|
|
75
98
|
// share a single source of truth for the input contract.
|
|
76
99
|
export const DecisionPageInputShape = {
|
|
77
100
|
ticket_key: z.string().describe("Jira ticket key, e.g. BAPI-123"),
|
|
101
|
+
output_subdir: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.default("review")
|
|
105
|
+
.describe('Optional docs-relative subdirectory to write the page under (default "review"). Validated strictly: no absolute paths, backslashes, ".." segments, null bytes, or encoded path tokens.'),
|
|
106
|
+
output_filename: z
|
|
107
|
+
.string()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe('Optional output filename (default "${ticket_key}-decisions.html"). Must end with .html and contain no path separators; the .html suffix is required and never auto-appended.'),
|
|
110
|
+
labels: DecisionPageLabelsSchema.optional().describe("Optional presentation-label overrides (title, intro, section_heading, improvements_heading). Presentation-only; does not change data-testid hooks or the submitted JSON shape."),
|
|
78
111
|
actionable_items: z
|
|
79
112
|
.array(ActionableItemSchema)
|
|
80
113
|
.optional()
|
|
@@ -31,6 +31,30 @@ 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
|
+
// Raw (unescaped) default label constants. They reproduce the legacy review copy
|
|
35
|
+
// exactly so omitting `labels` yields byte-identical output. Escaping happens at
|
|
36
|
+
// each render site, so these must stay raw to avoid double-escaping.
|
|
37
|
+
const DEFAULT_LABEL_TITLE = "Review Decisions";
|
|
38
|
+
const DEFAULT_LABEL_INTRO = PAGE_INTRO_ASK_COPY;
|
|
39
|
+
const DEFAULT_LABEL_SECTION_HEADING = "Review Decisions";
|
|
40
|
+
const DEFAULT_LABEL_IMPROVEMENTS_HEADING = "Confirmed Improvements";
|
|
41
|
+
/**
|
|
42
|
+
* Merge caller-supplied labels with the raw default constants using nullish
|
|
43
|
+
* coalescing (`??`), NOT logical OR — an explicit empty-string label is an
|
|
44
|
+
* intentional override and must be preserved rather than replaced by a default.
|
|
45
|
+
*/
|
|
46
|
+
function resolveDecisionPageLabels(labels) {
|
|
47
|
+
return {
|
|
48
|
+
title: labels?.title ?? DEFAULT_LABEL_TITLE,
|
|
49
|
+
intro: labels?.intro ?? DEFAULT_LABEL_INTRO,
|
|
50
|
+
section_heading: labels?.section_heading ?? DEFAULT_LABEL_SECTION_HEADING,
|
|
51
|
+
improvements_heading: labels?.improvements_heading ?? DEFAULT_LABEL_IMPROVEMENTS_HEADING,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** True only when a string is present and not whitespace-only. */
|
|
55
|
+
function hasDisplayText(value) {
|
|
56
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
57
|
+
}
|
|
34
58
|
// ---------------------------------------------------------------------------
|
|
35
59
|
// Template
|
|
36
60
|
// ---------------------------------------------------------------------------
|
|
@@ -38,17 +62,21 @@ const DEFAULT_ASSETS = { faviconBase64: "", logoBase64: "", fontsRelPath: "" };
|
|
|
38
62
|
export function generateDecisionPageHtml(data, assets = DEFAULT_ASSETS) {
|
|
39
63
|
const { ticket_key, actionable_items, clear_improvements } = data;
|
|
40
64
|
const hasDecisions = actionable_items.length > 0;
|
|
65
|
+
const effectiveLabels = resolveDecisionPageLabels(data.labels);
|
|
41
66
|
const faviconLink = assets.faviconBase64
|
|
42
67
|
? `<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,${assets.faviconBase64}">`
|
|
43
68
|
: "";
|
|
44
69
|
const fontFaces = assets.fontsRelPath ? renderFontFaces(assets.fontsRelPath) : "";
|
|
45
|
-
|
|
70
|
+
// 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;
|
|
46
74
|
return `<!DOCTYPE html>
|
|
47
75
|
<html lang="en">
|
|
48
76
|
<head>
|
|
49
77
|
<meta charset="UTF-8">
|
|
50
78
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
51
|
-
<title
|
|
79
|
+
<title>${escapeHtml(effectiveLabels.title)}: ${escapeHtml(ticket_key)}</title>
|
|
52
80
|
${faviconLink}
|
|
53
81
|
<style>
|
|
54
82
|
${fontFaces}
|
|
@@ -407,12 +435,12 @@ ${fontFaces}
|
|
|
407
435
|
${renderHeader(assets)}
|
|
408
436
|
<main class="main-content">
|
|
409
437
|
<div class="container">
|
|
410
|
-
<h1
|
|
438
|
+
<h1>${escapeHtml(effectiveLabels.title)}: ${escapeHtml(ticket_key)}</h1>
|
|
411
439
|
<p class="page-intro">${escapeHtml(pageIntro)}</p>
|
|
412
440
|
|
|
413
|
-
${hasDecisions ? renderForm(data) : renderNoDecisions()}
|
|
441
|
+
${hasDecisions ? renderForm(data, effectiveLabels) : renderNoDecisions()}
|
|
414
442
|
|
|
415
|
-
${renderImprovements(clear_improvements)}
|
|
443
|
+
${renderImprovements(clear_improvements, effectiveLabels)}
|
|
416
444
|
|
|
417
445
|
</div>
|
|
418
446
|
</main>
|
|
@@ -466,13 +494,13 @@ function renderNoDecisions() {
|
|
|
466
494
|
<p>No decisions needed. All suggestions were confirmed as improvements.</p>
|
|
467
495
|
</div>`;
|
|
468
496
|
}
|
|
469
|
-
function renderForm(data) {
|
|
497
|
+
function renderForm(data, labels) {
|
|
470
498
|
const { actionable_items } = data;
|
|
471
499
|
let html = ` <div id="form-container">
|
|
472
500
|
<form id="decision-form">`;
|
|
473
501
|
if (actionable_items.length > 0) {
|
|
474
502
|
html += `
|
|
475
|
-
<h2
|
|
503
|
+
<h2>${escapeHtml(labels.section_heading)}</h2>`;
|
|
476
504
|
for (const item of actionable_items) {
|
|
477
505
|
html += renderDecisionItem(item);
|
|
478
506
|
}
|
|
@@ -547,13 +575,25 @@ function renderDecisionItem(item) {
|
|
|
547
575
|
</div>`;
|
|
548
576
|
// Encode option labels for client-side JS to resolve chosen_label
|
|
549
577
|
const labelsJson = escapeHtml(JSON.stringify(item.options));
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
578
|
+
// Optional review-flavored display sections: omit entirely when the field is
|
|
579
|
+
// missing, empty, or whitespace-only so non-review callers can skip them.
|
|
580
|
+
const originalQuestionSection = hasDisplayText(item.original_question)
|
|
581
|
+
? `
|
|
553
582
|
<section class="card-section" data-testid="original-question-section" aria-labelledby="${originalQuestionLabelId}">
|
|
554
583
|
<div class="card-section-label" id="${originalQuestionLabelId}">Original question</div>
|
|
555
584
|
<div class="card-section-body" data-testid="original-question-body">${escapeHtml(item.original_question)}</div>
|
|
556
|
-
</section
|
|
585
|
+
</section>`
|
|
586
|
+
: "";
|
|
587
|
+
const codebaseEvidenceSection = hasDisplayText(item.codebase_evidence)
|
|
588
|
+
? `
|
|
589
|
+
<details class="codebase-evidence" data-testid="codebase-evidence">
|
|
590
|
+
<summary>Codebase evidence</summary>
|
|
591
|
+
<div class="codebase-evidence-body" data-testid="codebase-evidence-body">${escapeHtml(item.codebase_evidence)}</div>
|
|
592
|
+
</details>`
|
|
593
|
+
: "";
|
|
594
|
+
return `
|
|
595
|
+
<section class="card" data-item-id="${id}" data-item-type="decision" data-source="${escapeHtml(item.source ?? "")}" data-labels="${labelsJson}" data-testid="decision-card" aria-labelledby="${titleId}">
|
|
596
|
+
<h3 class="card-title" id="${titleId}" data-testid="decision-card-title">${escapeHtml(item.question)}</h3>${originalQuestionSection}
|
|
557
597
|
<section class="card-section" data-testid="why-it-matters-section" aria-labelledby="${whyItMattersLabelId}">
|
|
558
598
|
<div class="card-section-label" id="${whyItMattersLabelId}">Why it matters</div>
|
|
559
599
|
<div class="card-section-body" data-testid="why-it-matters-body">${escapeHtml(item.why_it_matters)}</div>
|
|
@@ -568,14 +608,10 @@ function renderDecisionItem(item) {
|
|
|
568
608
|
<div class="comment-area">
|
|
569
609
|
<label for="${textareaId}">Comment</label>
|
|
570
610
|
<textarea id="${textareaId}" name="${textareaId}" placeholder="Required for None of these selections..." data-testid="decision-comment"></textarea>
|
|
571
|
-
</div
|
|
572
|
-
<details class="codebase-evidence" data-testid="codebase-evidence">
|
|
573
|
-
<summary>Codebase evidence</summary>
|
|
574
|
-
<div class="codebase-evidence-body" data-testid="codebase-evidence-body">${escapeHtml(item.codebase_evidence)}</div>
|
|
575
|
-
</details>
|
|
611
|
+
</div>${codebaseEvidenceSection}
|
|
576
612
|
</section>`;
|
|
577
613
|
}
|
|
578
|
-
function renderImprovements(improvements) {
|
|
614
|
+
function renderImprovements(improvements, labels) {
|
|
579
615
|
if (improvements.length === 0)
|
|
580
616
|
return "";
|
|
581
617
|
let items = "";
|
|
@@ -593,7 +629,7 @@ function renderImprovements(improvements) {
|
|
|
593
629
|
</li>`;
|
|
594
630
|
}
|
|
595
631
|
return ` <section data-testid="confirmed-improvements">
|
|
596
|
-
<h2
|
|
632
|
+
<h2>${escapeHtml(labels.improvements_heading)}</h2>
|
|
597
633
|
<p>These improvements have been confirmed and will be applied. No action needed from you.</p>
|
|
598
634
|
<ul class="improvements-list">${items}
|
|
599
635
|
</ul>
|
package/build/doctor.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* spawning, no MCP server startup. It only ever runs read-only PATH probes
|
|
15
15
|
* (`which`/`where`, `bash --version`, `git rev-parse`) through the injected deps.
|
|
16
16
|
*/
|
|
17
|
+
import { readFile, stat } from "fs/promises";
|
|
18
|
+
import os from "os";
|
|
17
19
|
import { createDefaultStartTicketsDeps } from "./start-tickets.js";
|
|
18
20
|
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
|
|
19
21
|
import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
|
|
@@ -32,7 +34,8 @@ export function getDoctorUsage() {
|
|
|
32
34
|
" -h, --help Show this help",
|
|
33
35
|
"",
|
|
34
36
|
"Checks (for the current OS): the start-tickets preflight prerequisites plus",
|
|
35
|
-
"uv
|
|
37
|
+
"uv, the selected agent's command, Bridge API credential resolution, and",
|
|
38
|
+
"worktree MCP registration reachability.",
|
|
36
39
|
"",
|
|
37
40
|
"Exit code: 0 when all required prerequisites are present, non-zero otherwise.",
|
|
38
41
|
].join("\n");
|
|
@@ -100,9 +103,22 @@ export async function collectDoctorResults(deps, agentName) {
|
|
|
100
103
|
if (!descriptorsResult.ok) {
|
|
101
104
|
return { ok: false, unsupported: true, error: descriptorsResult.error };
|
|
102
105
|
}
|
|
106
|
+
// Augment with read-only filesystem deps so the doctor-only credential and
|
|
107
|
+
// worktree-MCP-registration probes can run. These are intentionally NOT part
|
|
108
|
+
// of StartTicketsDeps (live preflight never reads files); doctor injects them
|
|
109
|
+
// here, and they remain strictly read-only (readFile/stat/homedir only). If a
|
|
110
|
+
// caller (e.g. a unit test) already supplied them on `deps`, honor those so
|
|
111
|
+
// the probes are deterministic.
|
|
112
|
+
const injected = deps;
|
|
113
|
+
const probeDeps = {
|
|
114
|
+
...deps,
|
|
115
|
+
readFile: injected.readFile ?? ((p) => readFile(p, "utf-8")),
|
|
116
|
+
stat: injected.stat ?? ((p) => stat(p)),
|
|
117
|
+
homedir: injected.homedir ?? os.homedir,
|
|
118
|
+
};
|
|
103
119
|
const results = [];
|
|
104
120
|
for (const descriptor of descriptorsResult.descriptors) {
|
|
105
|
-
results.push(await probePrerequisite(
|
|
121
|
+
results.push(await probePrerequisite(probeDeps, descriptor));
|
|
106
122
|
}
|
|
107
123
|
return { ok: true, results };
|
|
108
124
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-only ignore helpers.
|
|
3
|
+
*
|
|
4
|
+
* Two idempotent, exact-line appenders:
|
|
5
|
+
* - `ensureGitignored` writes the tracked `<cwd>/.gitignore` (shared with the
|
|
6
|
+
* existing `--init` scaffolding behavior).
|
|
7
|
+
* - `ensureGitInfoExcluded` writes the UNtracked `<worktreeRoot>/.git/info/exclude`,
|
|
8
|
+
* used for Tier-3 file credentials that must never enter version control and
|
|
9
|
+
* must never touch the tracked `.gitignore`.
|
|
10
|
+
*
|
|
11
|
+
* Both are dependency-injected so they are unit-testable without real I/O, and
|
|
12
|
+
* neither ever includes credential file contents in an error.
|
|
13
|
+
*/
|
|
14
|
+
import path from "path";
|
|
15
|
+
/** True when `content` already contains `entry` as its own exact (trimmed) line. */
|
|
16
|
+
function hasExactLine(content, entry) {
|
|
17
|
+
return content.split("\n").some((line) => line.trim() === entry);
|
|
18
|
+
}
|
|
19
|
+
/** Append `entry` to `content`, inserting a separating newline only if needed. */
|
|
20
|
+
function appendLine(content, entry) {
|
|
21
|
+
const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
22
|
+
return content + separator + entry + "\n";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Idempotently ensure `filePath` is listed in `<cwd>/.gitignore`. An absolute
|
|
26
|
+
* `filePath` is made relative to `cwd`; an already-relative path is used as-is.
|
|
27
|
+
* No-op when the exact line already exists.
|
|
28
|
+
*/
|
|
29
|
+
export async function ensureGitignored(cwd, filePath, deps) {
|
|
30
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
31
|
+
const entry = path.isAbsolute(filePath) ? path.relative(cwd, filePath) : filePath;
|
|
32
|
+
let content = "";
|
|
33
|
+
try {
|
|
34
|
+
content = await deps.readFile(gitignorePath);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* .gitignore doesn't exist yet */
|
|
38
|
+
}
|
|
39
|
+
if (hasExactLine(content, entry))
|
|
40
|
+
return;
|
|
41
|
+
await deps.writeFile(gitignorePath, appendLine(content, entry));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Idempotently ensure `relativePath` is listed in the worktree's local-only
|
|
45
|
+
* `<worktreeRoot>/.git/info/exclude`. Creates `<worktreeRoot>/.git/info` if it
|
|
46
|
+
* does not exist. Matching is exact-line (so `dw.json` is not treated as present
|
|
47
|
+
* just because `dw.json.bak` exists). Never edits the tracked `.gitignore`.
|
|
48
|
+
*/
|
|
49
|
+
export async function ensureGitInfoExcluded(worktreeRoot, relativePath, deps) {
|
|
50
|
+
const infoDir = path.join(worktreeRoot, ".git", "info");
|
|
51
|
+
const excludePath = path.join(infoDir, "exclude");
|
|
52
|
+
let content = "";
|
|
53
|
+
try {
|
|
54
|
+
content = await deps.readFile(excludePath);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* exclude file (or .git/info) doesn't exist yet */
|
|
58
|
+
}
|
|
59
|
+
if (hasExactLine(content, relativePath))
|
|
60
|
+
return;
|
|
61
|
+
await deps.mkdir(infoDir, { recursive: true });
|
|
62
|
+
await deps.writeFile(excludePath, appendLine(content, relativePath));
|
|
63
|
+
}
|