@jam-mcp/server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/adapters/cache/noop-cache.d.ts +7 -0
  4. package/dist/adapters/cache/noop-cache.js +12 -0
  5. package/dist/adapters/credentials/composite.d.ts +30 -0
  6. package/dist/adapters/credentials/composite.js +79 -0
  7. package/dist/adapters/credentials/process-env.d.ts +13 -0
  8. package/dist/adapters/credentials/process-env.js +17 -0
  9. package/dist/adapters/credentials/secret-store.d.ts +69 -0
  10. package/dist/adapters/credentials/secret-store.js +259 -0
  11. package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
  12. package/dist/adapters/credentials/windows-user-env.js +51 -0
  13. package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
  14. package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
  15. package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
  16. package/dist/adapters/jira-cloud/jira-client.js +137 -0
  17. package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
  18. package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
  19. package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
  20. package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
  21. package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
  22. package/dist/adapters/jira-cloud/mapper.js +143 -0
  23. package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
  24. package/dist/adapters/telemetry/console-telemetry.js +31 -0
  25. package/dist/application/get-full-issue-context.d.ts +18 -0
  26. package/dist/application/get-full-issue-context.js +120 -0
  27. package/dist/application/get-issue-context.d.ts +19 -0
  28. package/dist/application/get-issue-context.js +79 -0
  29. package/dist/application/search-issues.d.ts +22 -0
  30. package/dist/application/search-issues.js +93 -0
  31. package/dist/bootstrap/boot-health-gate.d.ts +25 -0
  32. package/dist/bootstrap/boot-health-gate.js +137 -0
  33. package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
  34. package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
  35. package/dist/bootstrap/host-mcp.d.ts +54 -0
  36. package/dist/bootstrap/host-mcp.js +98 -0
  37. package/dist/bootstrap/jira-projects.d.ts +16 -0
  38. package/dist/bootstrap/jira-projects.js +24 -0
  39. package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
  40. package/dist/bootstrap/mcp-config-merger.js +119 -0
  41. package/dist/bootstrap/migration-target.d.ts +60 -0
  42. package/dist/bootstrap/migration-target.js +80 -0
  43. package/dist/bootstrap/project-bindings.d.ts +48 -0
  44. package/dist/bootstrap/project-bindings.js +112 -0
  45. package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
  46. package/dist/bootstrap/project-config-bootstrapper.js +69 -0
  47. package/dist/bootstrap/project-config-resolver.d.ts +53 -0
  48. package/dist/bootstrap/project-config-resolver.js +59 -0
  49. package/dist/bootstrap/project-root-resolver.d.ts +22 -0
  50. package/dist/bootstrap/project-root-resolver.js +43 -0
  51. package/dist/bootstrap/setup-apply.d.ts +31 -0
  52. package/dist/bootstrap/setup-apply.js +61 -0
  53. package/dist/bootstrap/setup-plan.d.ts +102 -0
  54. package/dist/bootstrap/setup-plan.js +224 -0
  55. package/dist/bootstrap/setup-state.d.ts +73 -0
  56. package/dist/bootstrap/setup-state.js +89 -0
  57. package/dist/bootstrap/workspace-identity.d.ts +52 -0
  58. package/dist/bootstrap/workspace-identity.js +114 -0
  59. package/dist/cli/agent-api.d.ts +63 -0
  60. package/dist/cli/agent-api.js +149 -0
  61. package/dist/cli/auth.d.ts +39 -0
  62. package/dist/cli/auth.js +201 -0
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.js +31 -0
  65. package/dist/cli/runtime.d.ts +16 -0
  66. package/dist/cli/runtime.js +97 -0
  67. package/dist/cli/serve.d.ts +7 -0
  68. package/dist/cli/serve.js +23 -0
  69. package/dist/cli/setup-wizard.d.ts +37 -0
  70. package/dist/cli/setup-wizard.js +338 -0
  71. package/dist/cli/setup.d.ts +26 -0
  72. package/dist/cli/setup.js +214 -0
  73. package/dist/cli/ui.d.ts +118 -0
  74. package/dist/cli/ui.js +338 -0
  75. package/dist/cli-entry.d.ts +7 -0
  76. package/dist/cli-entry.js +124 -0
  77. package/dist/config/load-config.d.ts +18 -0
  78. package/dist/config/load-config.js +48 -0
  79. package/dist/config/schema.d.ts +36 -0
  80. package/dist/config/schema.js +74 -0
  81. package/dist/deps.d.ts +50 -0
  82. package/dist/deps.js +39 -0
  83. package/dist/domain/completeness.d.ts +63 -0
  84. package/dist/domain/completeness.js +20 -0
  85. package/dist/domain/context.d.ts +38 -0
  86. package/dist/domain/context.js +1 -0
  87. package/dist/domain/errors.d.ts +27 -0
  88. package/dist/domain/errors.js +49 -0
  89. package/dist/domain/issue.d.ts +35 -0
  90. package/dist/domain/issue.js +1 -0
  91. package/dist/index.d.ts +2 -0
  92. package/dist/index.js +17 -0
  93. package/dist/mcp/create-server.d.ts +9 -0
  94. package/dist/mcp/create-server.js +32 -0
  95. package/dist/mcp/tool-result.d.ts +10 -0
  96. package/dist/mcp/tool-result.js +33 -0
  97. package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
  98. package/dist/mcp/tools/jira-context.tool.js +25 -0
  99. package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
  100. package/dist/mcp/tools/jira-full.tool.js +27 -0
  101. package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
  102. package/dist/mcp/tools/jira-search.tool.js +31 -0
  103. package/dist/policy/completeness-policy.d.ts +19 -0
  104. package/dist/policy/completeness-policy.js +34 -0
  105. package/dist/policy/consistency-policy.d.ts +13 -0
  106. package/dist/policy/consistency-policy.js +6 -0
  107. package/dist/policy/field-policy.d.ts +11 -0
  108. package/dist/policy/field-policy.js +26 -0
  109. package/dist/policy/output-budget-policy.d.ts +23 -0
  110. package/dist/policy/output-budget-policy.js +96 -0
  111. package/dist/policy/pagination-policy.d.ts +15 -0
  112. package/dist/policy/pagination-policy.js +13 -0
  113. package/dist/ports/cache.port.d.ts +5 -0
  114. package/dist/ports/cache.port.js +1 -0
  115. package/dist/ports/credentials.port.d.ts +27 -0
  116. package/dist/ports/credentials.port.js +1 -0
  117. package/dist/ports/jira-read.port.d.ts +73 -0
  118. package/dist/ports/jira-read.port.js +1 -0
  119. package/dist/ports/jira-write.port.d.ts +12 -0
  120. package/dist/ports/jira-write.port.js +1 -0
  121. package/dist/ports/telemetry.port.d.ts +13 -0
  122. package/dist/ports/telemetry.port.js +1 -0
  123. package/package.json +69 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * One key=value line per tool call on stderr. stdout is reserved for the MCP
3
+ * stdio protocol, so nothing here may ever write to stdout.
4
+ *
5
+ * Only the fields below are emitted - never credentials, headers, JQL bind
6
+ * values, or issue content.
7
+ */
8
+ export class ConsoleTelemetry {
9
+ enabled;
10
+ constructor(enabled = true) {
11
+ this.enabled = enabled;
12
+ }
13
+ recordTool(m) {
14
+ if (!this.enabled)
15
+ return;
16
+ const parts = [
17
+ `tool=${m.tool}`,
18
+ `duration_ms=${Math.round(m.durationMs)}`,
19
+ `jira_requests=${m.jiraRequests}`,
20
+ `issues=${m.issues}`,
21
+ `response_bytes=${m.responseBytes}`,
22
+ ];
23
+ if (m.pages !== undefined)
24
+ parts.push(`pages=${m.pages}`);
25
+ if (m.complete !== undefined)
26
+ parts.push(`complete=${m.complete}`);
27
+ if (m.errorCode)
28
+ parts.push(`error=${m.errorCode}`);
29
+ process.stderr.write(`[jam] ${parts.join(" ")}\n`);
30
+ }
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import { type CompletenessMeta } from "../domain/completeness.js";
3
+ import type { FullIssueContext } from "../domain/context.js";
4
+ export type GetFullIssueContextInput = {
5
+ issueKeys: string[];
6
+ };
7
+ export type GetFullIssueContextResult = {
8
+ issues: FullIssueContext[];
9
+ meta: CompletenessMeta;
10
+ };
11
+ /**
12
+ * Final-judgement path: description plus the complete comment thread.
13
+ *
14
+ * Comments are the whole point here - an agreement or approval call made on a
15
+ * partial thread is exactly the failure this tool exists to prevent, so the
16
+ * thread is paged to exhaustion and any shortfall is reported.
17
+ */
18
+ export declare function getFullIssueContext(deps: JamDeps, input: GetFullIssueContextInput): Promise<GetFullIssueContextResult>;
@@ -0,0 +1,120 @@
1
+ import { JIRA_EVIDENCE, nowIso } from "../domain/completeness.js";
2
+ import { fieldsFor } from "../policy/field-policy.js";
3
+ import { applyOutputBudget } from "../policy/output-budget-policy.js";
4
+ import { normalizeKeys, toContext, MAX_KEYS_PER_CALL } from "./get-issue-context.js";
5
+ const COMMENT_PAGE_SIZE = 100;
6
+ /** Safety stop for pathological threads; hitting it is reported, never silent. */
7
+ const MAX_COMMENT_PAGES = 20;
8
+ /**
9
+ * Final-judgement path: description plus the complete comment thread.
10
+ *
11
+ * Comments are the whole point here - an agreement or approval call made on a
12
+ * partial thread is exactly the failure this tool exists to prevent, so the
13
+ * thread is paged to exhaustion and any shortfall is reported.
14
+ */
15
+ export async function getFullIssueContext(deps, input) {
16
+ const started = performance.now();
17
+ const keys = normalizeKeys(input.issueKeys);
18
+ const fields = fieldsFor("full", deps.config);
19
+ const fetched = await deps.jira.getIssues({ keys, fields });
20
+ let jiraRequests = Math.ceil(keys.length / MAX_KEYS_PER_CALL) || 1;
21
+ let responseBytes = fetched.responseBytes;
22
+ const partialThreads = [];
23
+ for (const issue of fetched.issues) {
24
+ const total = fetched.commentTotals[issue.key] ?? issue.comments.length;
25
+ let pages = 0;
26
+ while (issue.comments.length < total && pages < MAX_COMMENT_PAGES) {
27
+ const page = await deps.jira.getComments({
28
+ key: issue.key,
29
+ startAt: issue.comments.length,
30
+ maxResults: COMMENT_PAGE_SIZE,
31
+ });
32
+ jiraRequests++;
33
+ responseBytes += page.responseBytes;
34
+ pages++;
35
+ if (page.comments.length === 0)
36
+ break;
37
+ issue.comments.push(...page.comments);
38
+ }
39
+ if (issue.comments.length < total)
40
+ partialThreads.push(issue.key);
41
+ }
42
+ const budget = applyOutputBudget(fetched.issues, deps.config.output.fullTokens);
43
+ const issues = budget.issues.map((issue) => toFull(issue));
44
+ const commentsComplete = budget.commentsComplete && partialThreads.length === 0;
45
+ const meta = {
46
+ ...JIRA_EVIDENCE,
47
+ level: "full",
48
+ complete: budget.complete && fetched.missingKeys.length === 0 && partialThreads.length === 0,
49
+ fetchedAt: nowIso(),
50
+ fieldsLoaded: fields,
51
+ commentsComplete,
52
+ linksComplete: budget.linksComplete,
53
+ };
54
+ const notes = [];
55
+ if (fetched.missingKeys.length > 0) {
56
+ meta.missingKeys = fetched.missingKeys;
57
+ meta.reason = "PARTIAL_API_RESPONSE";
58
+ notes.push(`${fetched.missingKeys.length} issue(s) could not be read - they may not exist or may not be visible to this account.`);
59
+ }
60
+ if (partialThreads.length > 0) {
61
+ meta.reason ??= "PARTIAL_API_RESPONSE";
62
+ meta.overflow = [...(meta.overflow ?? []), "comments"];
63
+ notes.push(`Comment thread not fully retrieved for: ${partialThreads.join(", ")}. Do not treat the discussion as settled.`);
64
+ }
65
+ if (budget.overflow.length > 0) {
66
+ meta.overflow = [...new Set([...(meta.overflow ?? []), ...budget.overflow])];
67
+ meta.reason ??= "OUTPUT_BUDGET";
68
+ notes.push(`Dropped ${budget.overflow.join(", ")}${budget.droppedComments > 0 ? ` (${budget.droppedComments} oldest comment(s))` : ""} to stay within the ${deps.config.output.fullTokens}-token full budget. Request a single issue key for the complete record.`);
69
+ }
70
+ if (notes.length > 0)
71
+ meta.notes = notes;
72
+ deps.telemetry.recordTool({
73
+ tool: "jira_full",
74
+ durationMs: performance.now() - started,
75
+ jiraRequests,
76
+ issues: issues.length,
77
+ responseBytes,
78
+ complete: meta.complete,
79
+ });
80
+ return { issues, meta };
81
+ }
82
+ function toFull(issue) {
83
+ const full = {
84
+ ...toContext(issue),
85
+ comments: issue.comments,
86
+ };
87
+ if (issue.description)
88
+ full.description = issue.description;
89
+ if (issue.history?.length)
90
+ full.history = issue.history;
91
+ const latest = latestCommentAt(issue.comments);
92
+ if (latest)
93
+ full.latestCommentAt = latest;
94
+ return full;
95
+ }
96
+ /**
97
+ * Max, not last: the first page arrives embedded in the bulk fetch in
98
+ * whatever order Jira returned it, and the output budget drops from the front,
99
+ * so position says nothing about recency. An edit counts - `updated` is only
100
+ * present when it differs from `created`.
101
+ */
102
+ function latestCommentAt(comments) {
103
+ let latest;
104
+ let latestMs = Number.NEGATIVE_INFINITY;
105
+ for (const comment of comments) {
106
+ const at = comment.updated ?? comment.created;
107
+ if (!at)
108
+ continue;
109
+ // Parsed rather than compared as text: Jira stamps an offset, and
110
+ // "...+0900" sorts below "...Z" as a string while being later in time.
111
+ const ms = Date.parse(at);
112
+ if (Number.isNaN(ms))
113
+ continue;
114
+ if (ms > latestMs) {
115
+ latestMs = ms;
116
+ latest = at;
117
+ }
118
+ }
119
+ return latest;
120
+ }
@@ -0,0 +1,19 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import { type CompletenessMeta } from "../domain/completeness.js";
3
+ import type { FullIssueContext, IssueContext } from "../domain/context.js";
4
+ export type GetIssueContextInput = {
5
+ issueKeys: string[];
6
+ };
7
+ export type GetIssueContextResult = {
8
+ issues: IssueContext[];
9
+ meta: CompletenessMeta;
10
+ };
11
+ export declare const MAX_KEYS_PER_CALL = 100;
12
+ /**
13
+ * Readiness path. One batched round trip for every requested key, plus the
14
+ * dependency structure needed to answer "can this start yet".
15
+ */
16
+ export declare function getIssueContext(deps: JamDeps, input: GetIssueContextInput): Promise<GetIssueContextResult>;
17
+ /** CONTEXT projection: everything but description/comments/history. */
18
+ export declare function toContext(issue: FullIssueContext): IssueContext;
19
+ export declare function normalizeKeys(raw: string[]): string[];
@@ -0,0 +1,79 @@
1
+ import { JIRA_EVIDENCE, nowIso } from "../domain/completeness.js";
2
+ import { JamError } from "../domain/errors.js";
3
+ import { fieldsFor } from "../policy/field-policy.js";
4
+ import { applyOutputBudget } from "../policy/output-budget-policy.js";
5
+ import { toSummary } from "./search-issues.js";
6
+ export const MAX_KEYS_PER_CALL = 100;
7
+ /**
8
+ * Readiness path. One batched round trip for every requested key, plus the
9
+ * dependency structure needed to answer "can this start yet".
10
+ */
11
+ export async function getIssueContext(deps, input) {
12
+ const started = performance.now();
13
+ const keys = normalizeKeys(input.issueKeys);
14
+ const fields = fieldsFor("context", deps.config);
15
+ const fetched = await deps.jira.getIssues({ keys, fields });
16
+ const budget = applyOutputBudget(fetched.issues, deps.config.output.contextTokens);
17
+ const issues = budget.issues.map(toContext);
18
+ const meta = {
19
+ ...JIRA_EVIDENCE,
20
+ level: "context",
21
+ complete: budget.complete && fetched.missingKeys.length === 0,
22
+ fetchedAt: nowIso(),
23
+ fieldsLoaded: fields,
24
+ linksComplete: budget.linksComplete,
25
+ };
26
+ const overflow = [...budget.overflow];
27
+ if (fetched.missingKeys.length > 0) {
28
+ meta.missingKeys = fetched.missingKeys;
29
+ meta.reason = "PARTIAL_API_RESPONSE";
30
+ meta.notes = [
31
+ `${fetched.missingKeys.length} issue(s) could not be read - they may not exist or may not be visible to this account.`,
32
+ ];
33
+ }
34
+ if (overflow.length > 0) {
35
+ meta.overflow = overflow;
36
+ meta.reason ??= "OUTPUT_BUDGET";
37
+ meta.notes = [
38
+ ...(meta.notes ?? []),
39
+ `Dropped ${overflow.join(", ")} to stay within the ${deps.config.output.contextTokens}-token context budget. Request fewer keys for the full picture.`,
40
+ ];
41
+ }
42
+ deps.telemetry.recordTool({
43
+ tool: "jira_context",
44
+ durationMs: performance.now() - started,
45
+ jiraRequests: Math.ceil(keys.length / MAX_KEYS_PER_CALL) || 1,
46
+ issues: issues.length,
47
+ responseBytes: fetched.responseBytes,
48
+ complete: meta.complete,
49
+ });
50
+ return { issues, meta };
51
+ }
52
+ /** CONTEXT projection: everything but description/comments/history. */
53
+ export function toContext(issue) {
54
+ const context = {
55
+ ...toSummary(issue),
56
+ subtasks: issue.subtasks,
57
+ links: issue.links,
58
+ customFields: issue.customFields,
59
+ };
60
+ if (issue.issueType)
61
+ context.issueType = issue.issueType;
62
+ if (issue.parent)
63
+ context.parent = issue.parent;
64
+ return context;
65
+ }
66
+ export function normalizeKeys(raw) {
67
+ const keys = [...new Set(raw.map((k) => k.trim().toUpperCase()).filter(Boolean))];
68
+ if (keys.length === 0) {
69
+ throw new JamError("CONFIG_INVALID", "issueKeys must contain at least one issue key.");
70
+ }
71
+ if (keys.length > MAX_KEYS_PER_CALL * 5) {
72
+ throw new JamError("CONTEXT_TOO_LARGE", `Too many issue keys in one call (${keys.length}). Split the request.`);
73
+ }
74
+ const malformed = keys.filter((k) => !/^[A-Z][A-Z0-9_]*-\d+$/.test(k));
75
+ if (malformed.length > 0) {
76
+ throw new JamError("CONFIG_INVALID", `Not valid Jira issue keys: ${malformed.join(", ")}`, { malformed });
77
+ }
78
+ return keys;
79
+ }
@@ -0,0 +1,22 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ import type { FullIssueContext } from "../domain/context.js";
3
+ import { type CompletenessMeta } from "../domain/completeness.js";
4
+ import type { IssueSummary } from "../domain/issue.js";
5
+ import { type SearchScope } from "../policy/pagination-policy.js";
6
+ export type SearchIssuesInput = {
7
+ jql: string;
8
+ scope?: SearchScope;
9
+ };
10
+ export type SearchIssuesResult = {
11
+ issues: IssueSummary[];
12
+ meta: CompletenessMeta;
13
+ };
14
+ /**
15
+ * Discovery path. Lite fields only, pagination owned by JAM.
16
+ *
17
+ * A `complete` search walks nextPageToken to exhaustion; if it hits the page
18
+ * cap it says so instead of presenting a partial list as the whole set.
19
+ */
20
+ export declare function searchIssues(deps: JamDeps, input: SearchIssuesInput): Promise<SearchIssuesResult>;
21
+ /** Project down to lite fields so heavy data cannot leak through this path. */
22
+ export declare function toSummary(issue: FullIssueContext): IssueSummary;
@@ -0,0 +1,93 @@
1
+ import { JIRA_EVIDENCE, nowIso } from "../domain/completeness.js";
2
+ import { fieldsFor } from "../policy/field-policy.js";
3
+ import { paginationFor } from "../policy/pagination-policy.js";
4
+ import { estimateTokens } from "../policy/output-budget-policy.js";
5
+ /**
6
+ * Discovery path. Lite fields only, pagination owned by JAM.
7
+ *
8
+ * A `complete` search walks nextPageToken to exhaustion; if it hits the page
9
+ * cap it says so instead of presenting a partial list as the whole set.
10
+ */
11
+ export async function searchIssues(deps, input) {
12
+ const started = performance.now();
13
+ const scope = input.scope ?? "preview";
14
+ const fields = fieldsFor("search", deps.config);
15
+ const plan = paginationFor(scope, deps.config);
16
+ const collected = [];
17
+ let pageToken;
18
+ let pagesFetched = 0;
19
+ let responseBytes = 0;
20
+ let truncatedByPageCap = false;
21
+ for (;;) {
22
+ const page = await deps.jira.searchPage({
23
+ jql: input.jql,
24
+ fields,
25
+ pageSize: plan.pageSize,
26
+ ...(pageToken ? { pageToken } : {}),
27
+ });
28
+ pagesFetched++;
29
+ responseBytes += page.responseBytes;
30
+ collected.push(...page.issues);
31
+ pageToken = page.nextPageToken;
32
+ if (!pageToken)
33
+ break;
34
+ if (pagesFetched >= plan.maxPages) {
35
+ // preview stops here by design; complete hitting the cap is an overflow.
36
+ truncatedByPageCap = scope === "complete";
37
+ break;
38
+ }
39
+ }
40
+ const issues = collected.map(toSummary);
41
+ const morePagesAvailable = Boolean(pageToken);
42
+ const meta = {
43
+ ...JIRA_EVIDENCE,
44
+ level: "search",
45
+ complete: !morePagesAvailable,
46
+ fetchedAt: nowIso(),
47
+ pagesFetched,
48
+ fieldsLoaded: fields,
49
+ };
50
+ if (morePagesAvailable) {
51
+ meta.reason = truncatedByPageCap ? "OUTPUT_BUDGET" : "PARTIAL_API_RESPONSE";
52
+ meta.overflow = ["pages"];
53
+ meta.notes = [
54
+ scope === "preview"
55
+ ? "Preview scope returns the first page only. Re-run with scope=\"complete\" to enumerate every match."
56
+ : `Stopped at the ${plan.maxPages}-page safety cap. Narrow the JQL or raise search.maxPages in project.yaml.`,
57
+ ];
58
+ }
59
+ const budget = deps.config.output.searchTokens;
60
+ const estimated = estimateTokens(issues);
61
+ if (estimated > budget) {
62
+ meta.notes = [
63
+ ...(meta.notes ?? []),
64
+ `Result is larger than the ${budget}-token search budget (~${estimated}). Narrow the JQL.`,
65
+ ];
66
+ }
67
+ deps.telemetry.recordTool({
68
+ tool: "jira_search",
69
+ durationMs: performance.now() - started,
70
+ jiraRequests: pagesFetched,
71
+ issues: issues.length,
72
+ responseBytes,
73
+ pages: pagesFetched,
74
+ complete: meta.complete,
75
+ });
76
+ return { issues, meta };
77
+ }
78
+ /** Project down to lite fields so heavy data cannot leak through this path. */
79
+ export function toSummary(issue) {
80
+ const summary = {
81
+ key: issue.key,
82
+ summary: issue.summary,
83
+ status: issue.status,
84
+ updated: issue.updated,
85
+ labels: issue.labels,
86
+ components: issue.components,
87
+ };
88
+ if (issue.assignee)
89
+ summary.assignee = issue.assignee;
90
+ if (issue.priority)
91
+ summary.priority = issue.priority;
92
+ return summary;
93
+ }
@@ -0,0 +1,25 @@
1
+ import type { JamDeps } from "../deps.js";
2
+ export type HealthCheck = {
3
+ name: string;
4
+ ok: boolean;
5
+ /** A failed fatal check blocks MCP startup; a non-fatal one is a warning. */
6
+ fatal: boolean;
7
+ detail?: string;
8
+ };
9
+ export type GateMode = "boot" | "full";
10
+ export type GateResult = {
11
+ mode: GateMode;
12
+ checks: HealthCheck[];
13
+ passed: boolean;
14
+ };
15
+ /**
16
+ * One health-check core shared by `jam doctor`, `jam setup` and `jam serve`.
17
+ *
18
+ * "boot" mode is what `jam serve` runs on every startup: local checks only
19
+ * (Node version, config, credentials, base URL shape, MCP wiring) - no network
20
+ * round trip, so it never adds Jira's latency to Claude Code's own startup.
21
+ * "full" mode adds live Jira connectivity checks and is what `jam doctor` and
22
+ * `jam setup` run, since those are explicit, occasional diagnostics rather
23
+ * than something on the hot path of every session.
24
+ */
25
+ export declare function runHealthGate(deps: JamDeps, mode: GateMode): Promise<GateResult>;
@@ -0,0 +1,137 @@
1
+ import { toJamError } from "../domain/errors.js";
2
+ import { createServer } from "../mcp/create-server.js";
3
+ /**
4
+ * One health-check core shared by `jam doctor`, `jam setup` and `jam serve`.
5
+ *
6
+ * "boot" mode is what `jam serve` runs on every startup: local checks only
7
+ * (Node version, config, credentials, base URL shape, MCP wiring) - no network
8
+ * round trip, so it never adds Jira's latency to Claude Code's own startup.
9
+ * "full" mode adds live Jira connectivity checks and is what `jam doctor` and
10
+ * `jam setup` run, since those are explicit, occasional diagnostics rather
11
+ * than something on the hot path of every session.
12
+ */
13
+ export async function runHealthGate(deps, mode) {
14
+ const checks = [];
15
+ const add = (c) => checks.push(c);
16
+ const major = Number(process.versions.node.split(".")[0]);
17
+ add({
18
+ name: "Node runtime",
19
+ ok: major >= 20,
20
+ fatal: true,
21
+ detail: major >= 20 ? `v${process.versions.node}` : `v${process.versions.node} (need >= 20)`,
22
+ });
23
+ add({
24
+ name: "Project config",
25
+ ok: true,
26
+ fatal: true,
27
+ // Three states, not two: a project with no config file may still have a
28
+ // key, supplied for this run only. Saying "using defaults" there would
29
+ // report the opposite of what JAM is about to do.
30
+ detail: deps.configPath
31
+ ? `${deps.configPath} (project=${deps.config.project.key || "unset"})`
32
+ : deps.keySource
33
+ ? `no project.yaml - project=${deps.config.project.key} from ${deps.keySource}`
34
+ : "using defaults (no .jira-agent/project.yaml found)",
35
+ });
36
+ const hasKey = Boolean(deps.config.project.key);
37
+ add({
38
+ name: "Jira project key",
39
+ ok: hasKey,
40
+ fatal: true,
41
+ detail: hasKey ? deps.config.project.key : "not set - run `jam setup --project <KEY>`",
42
+ });
43
+ const creds = deps.credentials.describe();
44
+ const credsOk = Boolean(creds.baseUrl && creds.email && creds.hasToken);
45
+ add({
46
+ name: "Credentials present",
47
+ ok: credsOk,
48
+ fatal: true,
49
+ // Never print the token itself - presence and source only.
50
+ detail: credsOk
51
+ ? `${creds.email} @ ${creds.baseUrl} (${creds.source})`
52
+ : "run `jam auth login`, or set JIRA_BASE_URL, JIRA_EMAIL and JIRA_API_TOKEN",
53
+ });
54
+ const baseUrlOk = Boolean(creds.baseUrl && /^https?:\/\//.test(creds.baseUrl));
55
+ add({
56
+ name: "Jira base URL",
57
+ ok: baseUrlOk,
58
+ fatal: true,
59
+ detail: baseUrlOk ? creds.baseUrl : "must start with http:// or https://",
60
+ });
61
+ try {
62
+ createServer(deps);
63
+ add({ name: "MCP server startup", ok: true, fatal: true, detail: "3 tools registered" });
64
+ }
65
+ catch (err) {
66
+ add({ name: "MCP server startup", ok: false, fatal: true, detail: toJamError(err).message });
67
+ }
68
+ const bootFatalFailed = checks.some((c) => c.fatal && !c.ok);
69
+ if (mode === "boot" || bootFatalFailed) {
70
+ return { mode, checks, passed: !bootFatalFailed };
71
+ }
72
+ // full mode: live Jira connectivity, only reached once every boot-level check passed.
73
+ try {
74
+ const me = await deps.jira.getCurrentUser();
75
+ add({
76
+ name: "Jira authentication",
77
+ ok: true,
78
+ fatal: true,
79
+ detail: me.displayName ?? me.emailAddress ?? me.accountId,
80
+ });
81
+ }
82
+ catch (err) {
83
+ add({ name: "Jira authentication", ok: false, fatal: true, detail: describeErr(err) });
84
+ return { mode, checks, passed: false };
85
+ }
86
+ const projectKey = deps.config.project.key;
87
+ let sampleKey;
88
+ try {
89
+ const page = await deps.jira.searchPage({
90
+ jql: `project = "${projectKey}" ORDER BY updated DESC`,
91
+ fields: ["summary", "status"],
92
+ pageSize: 1,
93
+ });
94
+ sampleKey = page.issues[0]?.key;
95
+ add({
96
+ name: `JQL search / ${projectKey} access`,
97
+ ok: true,
98
+ fatal: true,
99
+ detail: sampleKey ? `reachable (sample ${sampleKey})` : "reachable (project has no issues)",
100
+ });
101
+ }
102
+ catch (err) {
103
+ add({ name: `JQL search / ${projectKey} access`, ok: false, fatal: true, detail: describeErr(err) });
104
+ return { mode, checks, passed: false };
105
+ }
106
+ if (sampleKey) {
107
+ try {
108
+ const batch = await deps.jira.getIssues({
109
+ keys: [sampleKey],
110
+ fields: ["summary", "status", "issuelinks"],
111
+ });
112
+ add({
113
+ name: "Issue detail endpoint",
114
+ ok: batch.issues.length === 1,
115
+ fatal: false,
116
+ detail: batch.issues.length === 1 ? `read ${sampleKey}` : `could not read ${sampleKey}`,
117
+ });
118
+ }
119
+ catch (err) {
120
+ add({ name: "Issue detail endpoint", ok: false, fatal: false, detail: describeErr(err) });
121
+ }
122
+ }
123
+ else {
124
+ add({
125
+ name: "Issue detail endpoint",
126
+ ok: true,
127
+ fatal: false,
128
+ detail: "skipped - no issue available to sample",
129
+ });
130
+ }
131
+ const anyFatalFailed = checks.some((c) => c.fatal && !c.ok);
132
+ return { mode, checks, passed: !anyFatalFailed };
133
+ }
134
+ function describeErr(err) {
135
+ const e = toJamError(err);
136
+ return `${e.code}: ${e.message}`;
137
+ }
@@ -0,0 +1,15 @@
1
+ import { type BuildDepsOptions, type JamDeps } from "../deps.js";
2
+ import { type GateResult } from "./boot-health-gate.js";
3
+ export type BootstrapResult = {
4
+ deps: JamDeps;
5
+ gate: GateResult;
6
+ };
7
+ export type BootstrapForServeOptions = Pick<BuildDepsOptions, "cwd" | "jira" | "credentials" | "env" | "presetsPath" | "home" | "git">;
8
+ /**
9
+ * `jam serve`'s boot path: resolve the project config - reading a file if the
10
+ * project has one, otherwise falling back to an explicitly supplied key - then
11
+ * run the local-only "boot" gate. Nothing here writes to the repository, and
12
+ * no live Jira call happens either, so every `claude` startup stays fast even
13
+ * when Jira is slow or briefly unreachable.
14
+ */
15
+ export declare function bootstrapForServe(options?: BootstrapForServeOptions): Promise<BootstrapResult>;
@@ -0,0 +1,14 @@
1
+ import { buildDeps } from "../deps.js";
2
+ import { runHealthGate } from "./boot-health-gate.js";
3
+ /**
4
+ * `jam serve`'s boot path: resolve the project config - reading a file if the
5
+ * project has one, otherwise falling back to an explicitly supplied key - then
6
+ * run the local-only "boot" gate. Nothing here writes to the repository, and
7
+ * no live Jira call happens either, so every `claude` startup stays fast even
8
+ * when Jira is slow or briefly unreachable.
9
+ */
10
+ export async function bootstrapForServe(options = {}) {
11
+ const deps = await buildDeps({ ...options, keyFallback: "required" });
12
+ const gate = await runHealthGate(deps, "boot");
13
+ return { deps, gate };
14
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The coding agents JAM knows how to register itself with, for this user
3
+ * rather than for a repository.
4
+ *
5
+ * Both keep user-level MCP registration in a file of their own - Claude Code
6
+ * in `~/.claude.json`, Codex in `~/.codex/config.toml` - and JAM parses
7
+ * neither. Editing another program's live state file is how you corrupt it;
8
+ * each of these ships a command for exactly this, so JAM uses it.
9
+ */
10
+ export type HostId = "claude-code" | "codex";
11
+ export type HostCommand = {
12
+ command: string;
13
+ args: string[];
14
+ };
15
+ export type HostState = {
16
+ id: HostId;
17
+ /** Whether the host's own CLI is on PATH and answering. */
18
+ cliAvailable: boolean;
19
+ /** Whether it already has a `jam` entry registered for this user. */
20
+ hasJamEntry: boolean;
21
+ };
22
+ export type HostRunResult = {
23
+ status: number | null;
24
+ /** True when the command could not be run at all. */
25
+ failed: boolean;
26
+ stdout: string;
27
+ };
28
+ /** Injected by tests. Nothing in this module may reach a real CLI unasked. */
29
+ export type HostRunner = (command: HostCommand) => HostRunResult;
30
+ export declare const defaultHostRunner: HostRunner;
31
+ export declare function hostRegistration(id: HostId): HostCommand | undefined;
32
+ /**
33
+ * Is `jam` in this listing?
34
+ *
35
+ * Both CLIs exit 0 whether or not a server exists, so the exit code says
36
+ * nothing and the name column is the only signal available. Matched as the
37
+ * first token of a line so a server called `jam-something`, or the word
38
+ * appearing in a URL, cannot be mistaken for it.
39
+ *
40
+ * ponytail: this reads another program's table. If either changes its listing
41
+ * format the effect is a redundant registration attempt, not a wrong one -
42
+ * `mcp add` on an existing entry writes the same launcher line back.
43
+ */
44
+ export declare function listsJamEntry(stdout: string): boolean;
45
+ /**
46
+ * Ask each host what it has, and whether it is there at all.
47
+ *
48
+ * Read-only by construction: `mcp list` reports. A host that cannot be reached
49
+ * is recorded as unavailable rather than assumed empty - so planning has
50
+ * something to refuse rather than something to guess.
51
+ */
52
+ export declare function detectHosts(run?: HostRunner): HostState[];
53
+ /** How a person would do it by hand, for the hosts JAM could not reach. */
54
+ export declare function describeHostCommand({ command, args }: HostCommand): string;