codehere 0.6.1 → 0.7.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 (81) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +84 -5
  4. package/dist/application/use-cases/generate-narration.js +20 -3
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +218 -19
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/cost/budget-config.js +1 -1
  23. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  24. package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
  25. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  26. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  27. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  28. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  29. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  30. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  31. package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
  32. package/dist/infrastructure/orchestration/context-handoff.js +4 -3
  33. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  34. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  35. package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
  36. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  37. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  38. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  40. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  41. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  42. package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
  43. package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
  44. package/dist/infrastructure/orchestration/session-manager.js +142 -29
  45. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  46. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  47. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  48. package/dist/infrastructure/protocols/cloud-sync.js +10 -11
  49. package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
  50. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  51. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  52. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  53. package/dist/infrastructure/protocols/verify-page.js +941 -0
  54. package/dist/infrastructure/security/trust-scorer.js +76 -1
  55. package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
  56. package/dist/infrastructure/storage/project-thread-migration.js +14 -9
  57. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  58. package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
  59. package/dist/monitoring.js +1 -1
  60. package/dist/presentation/cli/commands/audit-command.js +126 -0
  61. package/dist/presentation/cli/commands/budget-command.js +2 -2
  62. package/dist/presentation/cli/commands/config-command.js +2 -2
  63. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  64. package/dist/presentation/cli/commands/help-command.js +51 -1
  65. package/dist/presentation/cli/commands/init-command.js +5 -4
  66. package/dist/presentation/cli/commands/memory-command.js +29 -15
  67. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  68. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  69. package/dist/presentation/cli/commands/report-command.js +6 -4
  70. package/dist/presentation/cli/commands/setup-command.js +71 -18
  71. package/dist/presentation/cli/commands/status-command.js +0 -4
  72. package/dist/presentation/cli/commands/thread-command.js +288 -0
  73. package/dist/presentation/cli/commands/verify-command.js +233 -0
  74. package/dist/presentation/cli/error-display.js +1 -1
  75. package/dist/presentation/cli/repl.js +52 -2
  76. package/dist/presentation/cli/task-deliverable.js +127 -0
  77. package/dist/testing/cassette.js +125 -0
  78. package/dist/testing/replay-adapter.js +166 -0
  79. package/dist/ui.js +54 -29
  80. package/dist/utils/typo-guard.js +1 -1
  81. package/package.json +4 -2
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Figma context-source adapter (v0.8 S1 · second reference adapter).
3
+ *
4
+ * URL shapes:
5
+ * figma://file/<file-key> · whole-file metadata + top-level frames
6
+ * figma://file/<file-key>/node/<node-id> · one node + its children, flattened
7
+ *
8
+ * Returns the design tree as indented plain text: frame names, types,
9
+ * dimensions, text content of TEXT nodes, fill colors when they're solid.
10
+ * Generated previews + raster bytes are skipped · agents read text, not
11
+ * pixels (overlay-renderer in v0.9 will surface raw frames separately).
12
+ *
13
+ * Auth: ctx.config['figma'] = { token: '<personal-access-token>' }
14
+ * Token comes from figma.com/developers/api · personal access token.
15
+ */
16
+ import { ContextSourceError, estimateTokens, } from '../context-source.js';
17
+ const FIGMA_API = 'https://api.figma.com/v1';
18
+ const MAX_TREE_NODES = 200;
19
+ const MAX_TEXT_PER_NODE = 240;
20
+ function parsePath(url) {
21
+ // figma://file/<key> · hostname = 'file', pathname = '/<key>'
22
+ // figma://file/<key>/node/<id>
23
+ const host = url.host || url.hostname; // 'file'
24
+ if (host !== 'file') {
25
+ throw new ContextSourceError('figma', 'invalid_url', `URL must start with figma://file/<key>; got host='${host}'`);
26
+ }
27
+ const parts = url.pathname.split('/').filter(Boolean);
28
+ if (!parts.length) {
29
+ throw new ContextSourceError('figma', 'invalid_url', 'missing file key in figma:// URL');
30
+ }
31
+ const fileKey = parts[0];
32
+ let nodeId = null;
33
+ if (parts.length >= 3 && parts[1] === 'node') {
34
+ nodeId = decodeURIComponent(parts.slice(2).join('/'));
35
+ }
36
+ return { fileKey, nodeId };
37
+ }
38
+ export const figmaSource = {
39
+ id: 'figma',
40
+ label: 'Figma',
41
+ scheme: 'figma',
42
+ description: 'Fetch a Figma file or single node (tree, dimensions, text content)',
43
+ isConfigured(ctx) {
44
+ const cfg = ctx.config['figma'];
45
+ if (!cfg)
46
+ return false;
47
+ if (cfg.oauth?.accessToken)
48
+ return true;
49
+ return typeof cfg.token === 'string' && cfg.token.length > 8;
50
+ },
51
+ async fetch(url, ctx) {
52
+ const cfg = ctx.config['figma'] ?? {};
53
+ // Figma supports BOTH a personal-access token (X-Figma-Token header) AND
54
+ // an OAuth bearer (Authorization: Bearer ...) · pick whichever is set.
55
+ let headers;
56
+ if (cfg.oauth?.accessToken) {
57
+ headers = { Authorization: `Bearer ${cfg.oauth.accessToken}` };
58
+ }
59
+ else if (cfg.token) {
60
+ headers = { 'X-Figma-Token': cfg.token };
61
+ }
62
+ else {
63
+ throw new ContextSourceError('figma', 'auth', 'no token or OAuth in ctx.config["figma"]');
64
+ }
65
+ const { fileKey, nodeId } = parsePath(url);
66
+ const endpoint = cfg.endpoint ?? FIGMA_API;
67
+ let res;
68
+ if (nodeId) {
69
+ const target = `${endpoint}/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(nodeId)}`;
70
+ res = await ctx.httpGet(target, headers);
71
+ }
72
+ else {
73
+ const target = `${endpoint}/files/${encodeURIComponent(fileKey)}?depth=2`;
74
+ res = await ctx.httpGet(target, headers);
75
+ }
76
+ if (res.status === 401 || res.status === 403) {
77
+ throw new ContextSourceError('figma', 'auth', `Figma returned ${res.status}`);
78
+ }
79
+ if (res.status === 404) {
80
+ throw new ContextSourceError('figma', 'not_found', `file ${fileKey} not found`);
81
+ }
82
+ if (res.status === 429) {
83
+ throw new ContextSourceError('figma', 'rate_limit', 'Figma rate limit hit');
84
+ }
85
+ if (res.status !== 200) {
86
+ throw new ContextSourceError('figma', 'network', `Figma returned ${res.status}: ${res.body.slice(0, 200)}`);
87
+ }
88
+ let text;
89
+ let metadata;
90
+ if (nodeId) {
91
+ const parsed = JSON.parse(res.body);
92
+ if (parsed.err)
93
+ throw new ContextSourceError('figma', 'parse', `Figma error: ${parsed.err}`);
94
+ const node = parsed.nodes?.[nodeId]?.document;
95
+ if (!node)
96
+ throw new ContextSourceError('figma', 'not_found', `node ${nodeId} not in response`);
97
+ text = renderNodeAsTree(node, fileKey, nodeId);
98
+ metadata = { fileKey, nodeId, type: node.type, name: node.name };
99
+ }
100
+ else {
101
+ const parsed = JSON.parse(res.body);
102
+ if (parsed.err)
103
+ throw new ContextSourceError('figma', 'parse', `Figma error: ${parsed.err}`);
104
+ if (!parsed.document)
105
+ throw new ContextSourceError('figma', 'parse', 'Figma response missing document');
106
+ text = renderFile(parsed, fileKey);
107
+ metadata = { fileKey, name: parsed.name ?? null, lastModified: parsed.lastModified ?? null };
108
+ }
109
+ return {
110
+ url: url.toString(),
111
+ source: 'figma',
112
+ fetchedAt: new Date(ctx.now ? ctx.now() : Date.now()).toISOString(),
113
+ text,
114
+ estimatedTokens: estimateTokens(text),
115
+ metadata,
116
+ };
117
+ },
118
+ };
119
+ function renderFile(file, fileKey) {
120
+ const lines = [];
121
+ lines.push(`Figma file · ${file.name ?? '(unnamed)'}`);
122
+ lines.push(`https://www.figma.com/file/${fileKey}`);
123
+ if (file.lastModified)
124
+ lines.push(`updated: ${file.lastModified}`);
125
+ lines.push('');
126
+ if (file.document) {
127
+ lines.push('--- top-level tree (depth 2) ---');
128
+ renderInto(lines, file.document, 0, { remaining: MAX_TREE_NODES });
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+ function renderNodeAsTree(node, fileKey, nodeId) {
133
+ const lines = [];
134
+ lines.push(`Figma node · ${node.name} (${node.type})`);
135
+ lines.push(`https://www.figma.com/file/${fileKey}?node-id=${encodeURIComponent(nodeId)}`);
136
+ lines.push('');
137
+ renderInto(lines, node, 0, { remaining: MAX_TREE_NODES });
138
+ return lines.join('\n');
139
+ }
140
+ function renderInto(out, node, depth, budget) {
141
+ if (budget.remaining <= 0) {
142
+ if (budget.remaining === 0) {
143
+ out.push('...[tree truncated · node budget reached]');
144
+ budget.remaining = -1;
145
+ }
146
+ return;
147
+ }
148
+ budget.remaining -= 1;
149
+ const indent = ' '.repeat(depth);
150
+ const bb = node.absoluteBoundingBox;
151
+ const dim = bb ? ` ${Math.round(bb.width)}×${Math.round(bb.height)}` : '';
152
+ const visible = node.visible === false ? ' [hidden]' : '';
153
+ let line = `${indent}${node.type} · ${node.name}${dim}${visible}`;
154
+ if (node.characters) {
155
+ const t = node.characters.replace(/\s+/g, ' ').slice(0, MAX_TEXT_PER_NODE);
156
+ line += ` · "${t}${node.characters.length > MAX_TEXT_PER_NODE ? '…' : ''}"`;
157
+ }
158
+ out.push(line);
159
+ for (const child of node.children ?? []) {
160
+ renderInto(out, child, depth + 1, budget);
161
+ }
162
+ }
163
+ //# sourceMappingURL=figma-source.js.map
@@ -0,0 +1,159 @@
1
+ /**
2
+ * GitHub Issues context-source adapter (v0.8 S5 · community-shaped reference).
3
+ *
4
+ * Demonstrates that the context-source primitive isn't Linear/Figma-shaped
5
+ * by accident · the same contract serves a no-auth REST API. Any community
6
+ * contributor should be able to ship an adapter by copying THIS file, not
7
+ * the OAuth-heavy linear-source.ts.
8
+ *
9
+ * URL shape: `github://<owner>/<repo>/issues/<number>` (or `/pull/<number>`,
10
+ * which GitHub treats as an issue under the hood).
11
+ *
12
+ * Public-issues-only by default · no auth needed. If ctx.config['github']
13
+ * provides a `token`, we send it as a Bearer header to raise the rate
14
+ * limit + read private repos the token has scopes for.
15
+ *
16
+ * Returns: title, state, author, body, last N comments. Same shape as
17
+ * linear-source so prompts feel uniform across providers.
18
+ */
19
+ import { ContextSourceError, estimateTokens, } from '../context-source.js';
20
+ const GH_API = 'https://api.github.com';
21
+ const MAX_BODY_CHARS = 4000;
22
+ const MAX_COMMENT_CHARS = 600;
23
+ const MAX_COMMENTS = 10;
24
+ function parseRef(url) {
25
+ // URL: `github://<owner>/<repo>/issues/<number>` or `.../pull/<number>`
26
+ const host = url.host || url.hostname;
27
+ const path = url.pathname || '';
28
+ // Path begins with '/' · split
29
+ const parts = [host, ...path.split('/').filter(Boolean)];
30
+ if (parts.length < 4) {
31
+ throw new ContextSourceError('github', 'invalid_url', 'use github://<owner>/<repo>/issues/<number> or github://<owner>/<repo>/pull/<number>');
32
+ }
33
+ const [owner, repo, kind, numStr] = parts;
34
+ if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) {
35
+ throw new ContextSourceError('github', 'invalid_url', 'owner/repo must be alphanumeric');
36
+ }
37
+ if (kind !== 'issues' && kind !== 'pull') {
38
+ throw new ContextSourceError('github', 'invalid_url', `expected /issues/ or /pull/ · got /${kind}/`);
39
+ }
40
+ const number = Number.parseInt(numStr, 10);
41
+ if (!Number.isInteger(number) || number <= 0) {
42
+ throw new ContextSourceError('github', 'invalid_url', `not an issue number: '${numStr}'`);
43
+ }
44
+ return { owner, repo, number };
45
+ }
46
+ export const githubIssueSource = {
47
+ id: 'github',
48
+ label: 'GitHub Issues',
49
+ scheme: 'github',
50
+ description: 'Fetch a public GitHub issue or PR (title, body, recent comments) · no auth needed',
51
+ isConfigured() {
52
+ // Always available · public issues need no auth.
53
+ return true;
54
+ },
55
+ async fetch(url, ctx) {
56
+ const ref = parseRef(url);
57
+ const cfg = ctx.config['github'] ?? {};
58
+ const endpoint = cfg.endpoint ?? GH_API;
59
+ const headers = {
60
+ Accept: 'application/vnd.github+json',
61
+ 'X-GitHub-Api-Version': '2022-11-28',
62
+ 'User-Agent': 'codehere/0.8',
63
+ };
64
+ if (cfg.token)
65
+ headers['Authorization'] = `Bearer ${cfg.token}`;
66
+ const issueRes = await ctx.httpGet(`${endpoint}/repos/${ref.owner}/${ref.repo}/issues/${ref.number}`, headers);
67
+ if (issueRes.status === 404) {
68
+ throw new ContextSourceError('github', 'not_found', `${ref.owner}/${ref.repo}#${ref.number} not found`);
69
+ }
70
+ // GitHub returns 403 for BOTH rate-limit and auth · disambiguate by body
71
+ // sniff first (rate-limit responses say "API rate limit exceeded").
72
+ if (issueRes.status === 429 || (issueRes.status === 403 && /rate limit/i.test(issueRes.body))) {
73
+ throw new ContextSourceError('github', 'rate_limit', 'GitHub API rate limit hit');
74
+ }
75
+ if (issueRes.status === 401 || issueRes.status === 403) {
76
+ throw new ContextSourceError('github', 'auth', `GitHub returned ${issueRes.status} · public issues should work without a token; private ones need ctx.config["github"].token`);
77
+ }
78
+ if (issueRes.status !== 200) {
79
+ throw new ContextSourceError('github', 'network', `GitHub returned ${issueRes.status}: ${issueRes.body.slice(0, 200)}`);
80
+ }
81
+ let issue;
82
+ try {
83
+ issue = JSON.parse(issueRes.body);
84
+ }
85
+ catch (err) {
86
+ throw new ContextSourceError('github', 'parse', `GitHub issue response not JSON: ${err instanceof Error ? err.message : String(err)}`);
87
+ }
88
+ // Comments are paginated · fetch the most recent page (sort=created direction=desc)
89
+ let comments = [];
90
+ if ((issue.comments ?? 0) > 0) {
91
+ const cRes = await ctx.httpGet(`${endpoint}/repos/${ref.owner}/${ref.repo}/issues/${ref.number}/comments?per_page=${MAX_COMMENTS}&sort=created&direction=desc`, headers);
92
+ if (cRes.status === 200) {
93
+ try {
94
+ comments = JSON.parse(cRes.body).reverse(); // chronological for display
95
+ }
96
+ catch {
97
+ // Non-fatal · we still have the issue body.
98
+ comments = [];
99
+ }
100
+ }
101
+ }
102
+ const text = renderIssueAsPlainText(ref, issue, comments);
103
+ return {
104
+ url: url.toString(),
105
+ source: 'github',
106
+ fetchedAt: new Date(ctx.now ? ctx.now() : Date.now()).toISOString(),
107
+ text,
108
+ estimatedTokens: estimateTokens(text),
109
+ metadata: {
110
+ owner: ref.owner,
111
+ repo: ref.repo,
112
+ number: ref.number,
113
+ title: issue.title,
114
+ state: issue.state,
115
+ author: issue.user?.login ?? null,
116
+ html_url: issue.html_url,
117
+ commentCount: comments.length,
118
+ },
119
+ };
120
+ },
121
+ };
122
+ function renderIssueAsPlainText(ref, issue, comments) {
123
+ const lines = [];
124
+ lines.push(`GitHub ${ref.owner}/${ref.repo}#${issue.number} · ${issue.title}`);
125
+ lines.push(issue.html_url);
126
+ lines.push('');
127
+ lines.push(`state: ${issue.state}`);
128
+ if (issue.user?.login)
129
+ lines.push(`author: ${issue.user.login}`);
130
+ if (issue.assignee?.login)
131
+ lines.push(`assignee: ${issue.assignee.login}`);
132
+ if (issue.labels?.length) {
133
+ const names = issue.labels
134
+ .map((l) => l.name)
135
+ .filter(Boolean)
136
+ .join(', ');
137
+ if (names)
138
+ lines.push(`labels: ${names}`);
139
+ }
140
+ lines.push(`updated: ${issue.updated_at}`);
141
+ lines.push('');
142
+ lines.push('--- body ---');
143
+ const body = (issue.body ?? '').slice(0, MAX_BODY_CHARS);
144
+ lines.push(body || '(no body)');
145
+ if ((issue.body ?? '').length > MAX_BODY_CHARS) {
146
+ lines.push(`...[truncated · body is ${issue.body.length} chars]`);
147
+ }
148
+ if (comments.length) {
149
+ lines.push('');
150
+ lines.push(`--- last ${comments.length} comment${comments.length === 1 ? '' : 's'} ---`);
151
+ for (const c of comments) {
152
+ const who = c.user?.login ?? 'unknown';
153
+ const body = (c.body ?? '').slice(0, MAX_COMMENT_CHARS).replace(/\s+\n/g, '\n');
154
+ lines.push(`[${c.created_at}] ${who}: ${body}${(c.body ?? '').length > MAX_COMMENT_CHARS ? '...' : ''}`);
155
+ }
156
+ }
157
+ return lines.join('\n');
158
+ }
159
+ //# sourceMappingURL=github-issue-source.js.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Linear context-source adapter (v0.8 S1 · first reference adapter).
3
+ *
4
+ * URL shape: `linear://<TEAM-KEY>-<NUMBER>` · e.g. `linear://INFRA-247`.
5
+ *
6
+ * Returns the issue's title, status, assignee, description, and the last N
7
+ * comments as plain text. Token usage is bounded · a 5K-char issue with
8
+ * a 200-comment thread doesn't get spammed in full · we keep the top of the
9
+ * description + the last 10 comments by default.
10
+ *
11
+ * Auth: ctx.config['linear'] = { apiKey: '<personal-api-key>' }
12
+ * Get a key from linear.app/settings/api · personal key OR OAuth-issued token.
13
+ *
14
+ * Local-first: codehere never stores the issue payload on disk · the agent
15
+ * reads it once into prompt context, the audit chain records WHEN we fetched
16
+ * and BYTES we got but never the content. If the user wants persistence,
17
+ * that's their git tree.
18
+ */
19
+ import { ContextSourceError, estimateTokens, } from '../context-source.js';
20
+ const LINEAR_API = 'https://api.linear.app/graphql';
21
+ const MAX_DESCRIPTION_CHARS = 4000;
22
+ const MAX_COMMENT_CHARS = 600;
23
+ const MAX_COMMENTS = 10;
24
+ /** Resolve the auth header value from either OAuth or manual key. */
25
+ function authToken(cfg) {
26
+ if (cfg.oauth?.accessToken)
27
+ return `Bearer ${cfg.oauth.accessToken}`;
28
+ if (cfg.apiKey)
29
+ return cfg.apiKey;
30
+ return null;
31
+ }
32
+ function parseIdentifier(parsed) {
33
+ // URL parses `linear://INFRA-247` with host = 'infra-247' (lowercased).
34
+ // The pathname is empty. Reassemble + uppercase the team key.
35
+ const host = parsed.host || parsed.hostname;
36
+ if (!host)
37
+ throw new ContextSourceError('linear', 'invalid_url', 'no issue identifier in URL');
38
+ // Linear identifiers are <TEAM>-<NUMBER> · normalize case so callers can
39
+ // write `linear://infra-247` and we still hit `INFRA-247`.
40
+ const upper = host.toUpperCase();
41
+ if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(upper)) {
42
+ throw new ContextSourceError('linear', 'invalid_url', `not a Linear identifier: '${host}'`);
43
+ }
44
+ return upper;
45
+ }
46
+ const ISSUE_QUERY = `query($id: String!) {
47
+ issue(id: $id) {
48
+ id identifier title description url priority priorityLabel createdAt updatedAt
49
+ state { name type }
50
+ assignee { name }
51
+ team { key name }
52
+ comments(last: ${MAX_COMMENTS}) {
53
+ nodes { id body createdAt user { name } }
54
+ }
55
+ }
56
+ }`;
57
+ export const linearSource = {
58
+ id: 'linear',
59
+ label: 'Linear',
60
+ scheme: 'linear',
61
+ description: 'Fetch a Linear issue (title, status, description, recent comments)',
62
+ isConfigured(ctx) {
63
+ const cfg = ctx.config['linear'];
64
+ if (!cfg)
65
+ return false;
66
+ if (cfg.oauth?.accessToken)
67
+ return true;
68
+ return typeof cfg.apiKey === 'string' && cfg.apiKey.length > 8;
69
+ },
70
+ async fetch(url, ctx) {
71
+ const cfg = ctx.config['linear'] ?? {};
72
+ const auth = authToken(cfg);
73
+ if (!auth) {
74
+ throw new ContextSourceError('linear', 'auth', 'no apiKey or OAuth token in ctx.config["linear"]');
75
+ }
76
+ const identifier = parseIdentifier(url);
77
+ const endpoint = cfg.endpoint ?? LINEAR_API;
78
+ const headers = {
79
+ Authorization: auth,
80
+ 'Content-Type': 'application/json',
81
+ };
82
+ const body = JSON.stringify({ query: ISSUE_QUERY, variables: { id: identifier } });
83
+ // ContextSourceFetchContext gives us httpGet; we POST GraphQL via a
84
+ // small helper since httpGet is GET-only in the canonical signature.
85
+ // For now we go through the same path but with a `body` smuggled in
86
+ // via headers · NOT pretty. Future: add httpPost to the context. The
87
+ // shape is identical (status, body, headers) so adapters port cleanly.
88
+ const httpPost = ctx.httpPost;
89
+ let res;
90
+ if (httpPost) {
91
+ res = await httpPost(endpoint, body, headers);
92
+ }
93
+ else {
94
+ // Production fallback · use fetch directly. Tests inject httpPost so
95
+ // they never reach this branch.
96
+ const fr = await fetch(endpoint, { method: 'POST', headers, body });
97
+ res = {
98
+ status: fr.status,
99
+ body: await fr.text(),
100
+ headers: Object.fromEntries(fr.headers),
101
+ };
102
+ }
103
+ if (res.status === 401 || res.status === 403) {
104
+ throw new ContextSourceError('linear', 'auth', `Linear returned ${res.status}`);
105
+ }
106
+ if (res.status === 404) {
107
+ throw new ContextSourceError('linear', 'not_found', `issue ${identifier} not found`);
108
+ }
109
+ if (res.status === 429) {
110
+ throw new ContextSourceError('linear', 'rate_limit', 'Linear rate limit hit');
111
+ }
112
+ if (res.status !== 200) {
113
+ throw new ContextSourceError('linear', 'network', `Linear returned ${res.status}: ${res.body.slice(0, 200)}`);
114
+ }
115
+ let payload;
116
+ try {
117
+ payload = JSON.parse(res.body);
118
+ }
119
+ catch (err) {
120
+ throw new ContextSourceError('linear', 'parse', `Linear response not JSON: ${err instanceof Error ? err.message : String(err)}`);
121
+ }
122
+ if (payload.errors?.length) {
123
+ throw new ContextSourceError('linear', 'parse', `Linear GraphQL errors: ${payload.errors.map((e) => e.message).join('; ')}`);
124
+ }
125
+ const issue = payload.data?.issue;
126
+ if (!issue) {
127
+ throw new ContextSourceError('linear', 'not_found', `issue ${identifier} returned null`);
128
+ }
129
+ const text = renderIssueAsPlainText(issue);
130
+ return {
131
+ url: url.toString(),
132
+ source: 'linear',
133
+ fetchedAt: new Date(ctx.now ? ctx.now() : Date.now()).toISOString(),
134
+ text,
135
+ estimatedTokens: estimateTokens(text),
136
+ metadata: {
137
+ identifier: issue.identifier,
138
+ title: issue.title,
139
+ url: issue.url,
140
+ state: issue.state?.name,
141
+ stateType: issue.state?.type,
142
+ assignee: issue.assignee?.name ?? null,
143
+ priorityLabel: issue.priorityLabel ?? null,
144
+ priority: issue.priority,
145
+ description: issue.description ?? null,
146
+ commentCount: issue.comments?.nodes.length ?? 0,
147
+ // Structured comments for the overlay-renderer's Linear inline view.
148
+ comments: (issue.comments?.nodes ?? []).map((c) => ({
149
+ author: c.user?.name ?? 'unknown',
150
+ body: c.body,
151
+ createdAt: c.createdAt,
152
+ })),
153
+ },
154
+ };
155
+ },
156
+ };
157
+ function renderIssueAsPlainText(issue) {
158
+ const lines = [];
159
+ lines.push(`Linear issue ${issue.identifier} · ${issue.title}`);
160
+ lines.push(issue.url);
161
+ lines.push('');
162
+ if (issue.state)
163
+ lines.push(`status: ${issue.state.name} (${issue.state.type})`);
164
+ if (issue.assignee)
165
+ lines.push(`assignee: ${issue.assignee.name}`);
166
+ if (issue.priorityLabel)
167
+ lines.push(`priority: ${issue.priorityLabel}`);
168
+ if (issue.team)
169
+ lines.push(`team: ${issue.team.name} (${issue.team.key})`);
170
+ lines.push(`updated: ${issue.updatedAt}`);
171
+ lines.push('');
172
+ lines.push('--- description ---');
173
+ const desc = (issue.description ?? '').slice(0, MAX_DESCRIPTION_CHARS);
174
+ lines.push(desc || '(no description)');
175
+ if ((issue.description ?? '').length > MAX_DESCRIPTION_CHARS) {
176
+ lines.push(`...[truncated · description is ${issue.description.length} chars]`);
177
+ }
178
+ const comments = issue.comments?.nodes ?? [];
179
+ if (comments.length) {
180
+ lines.push('');
181
+ lines.push(`--- last ${comments.length} comment${comments.length === 1 ? '' : 's'} ---`);
182
+ for (const c of comments) {
183
+ const who = c.user?.name ?? 'unknown';
184
+ const body = c.body.slice(0, MAX_COMMENT_CHARS).replace(/\s+\n/g, '\n');
185
+ lines.push(`[${c.createdAt}] ${who}: ${body}${c.body.length > MAX_COMMENT_CHARS ? '...' : ''}`);
186
+ }
187
+ }
188
+ return lines.join('\n');
189
+ }
190
+ //# sourceMappingURL=linear-source.js.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Context resolver · v0.8 S2 · integrates context-sources into the spawn path.
3
+ *
4
+ * What it does: scans the user's prompt for any context-source URL
5
+ * (linear://..., figma://..., draw.io://..., etc.), resolves each one via
6
+ * the registry, and returns an enriched prompt with the resolved content
7
+ * appended as a structured preamble. Audit lines emit on every resolve.
8
+ *
9
+ * Why a separate module: the registry is pure data and the adapters are
10
+ * pure async functions, but spawn() is synchronous-feeling code. This module
11
+ * is the seam where we wait on async fetches before the agent starts.
12
+ *
13
+ * Two principles:
14
+ * 1. Best-effort by default. If one URL fails (auth, network, 404), we log
15
+ * the error inline in the preamble and CONTINUE with the others rather
16
+ * than failing the whole spawn. The agent still gets the user's prompt
17
+ * + whatever resolved; the audit chain still gets every error.
18
+ * 2. The resolved content goes into the prompt as a `[context-source ...]`
19
+ * block, NOT as a tool-call result. Tool-call wiring belongs in the
20
+ * adapters themselves; this layer is a pre-prompt enrichment.
21
+ */
22
+ import { fetchViaRegistry, } from './context-source.js';
23
+ /* ──────────────────────────────────────────────────────────────────────── */
24
+ /* URL extraction */
25
+ /* ──────────────────────────────────────────────────────────────────────── */
26
+ /**
27
+ * Extract every URL whose scheme is registered in the given registry. We do
28
+ * NOT extract `http(s)` URLs · those are usually documentation links, not
29
+ * something to inline.
30
+ *
31
+ * Scheme matching: case-insensitive; URLs can appear inline mid-sentence
32
+ * surrounded by punctuation. We tokenize loosely (whitespace + a few
33
+ * trailing punctuation chars) so `(linear://INFRA-247).` doesn't include
34
+ * the period or paren.
35
+ */
36
+ export function extractContextSourceUrls(prompt, reg) {
37
+ if (!prompt)
38
+ return [];
39
+ const schemes = Array.from(reg.bySchemeOnly.keys());
40
+ if (!schemes.length)
41
+ return [];
42
+ // Build a regex that anchors on any registered scheme.
43
+ const schemePart = schemes.map(escapeRe).join('|');
44
+ // Tokens: <scheme>://<chars-until-trailing-punct-or-whitespace>
45
+ const re = new RegExp(`\\b(${schemePart})://[^\\s<>"\`]+`, 'gi');
46
+ const seen = new Set();
47
+ const out = [];
48
+ for (const match of prompt.matchAll(re)) {
49
+ // Strip trailing punctuation a user is likely to include accidentally.
50
+ const cleaned = match[0].replace(/[.,;:!?)\]}]+$/, '');
51
+ if (!seen.has(cleaned)) {
52
+ seen.add(cleaned);
53
+ out.push(cleaned);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ function escapeRe(s) {
59
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ }
61
+ /**
62
+ * Walk the prompt, resolve every registered URL in parallel, append the
63
+ * payloads as a preamble. Best-effort: any single failure is logged in the
64
+ * preamble and in the audit chain; the spawn proceeds.
65
+ */
66
+ export async function resolveContextSourceUrls(prompt, reg, ctx) {
67
+ const urls = extractContextSourceUrls(prompt, reg);
68
+ if (urls.length === 0) {
69
+ return { enrichedPrompt: prompt, outcomes: [], totalEstimatedTokens: 0 };
70
+ }
71
+ // Resolve in parallel · network-bound, no shared state, safe to fanout.
72
+ // Cap fanout to a small number to avoid hammering one provider when a
73
+ // user pastes 50 Linear URLs at once.
74
+ const MAX_PARALLEL = 4;
75
+ const outcomes = [];
76
+ const payloads = [];
77
+ for (let i = 0; i < urls.length; i += MAX_PARALLEL) {
78
+ const batch = urls.slice(i, i + MAX_PARALLEL);
79
+ const settled = await Promise.allSettled(batch.map((u) => fetchViaRegistry(reg, u, ctx).then((p) => ({ url: u, payload: p }))));
80
+ for (let j = 0; j < settled.length; j++) {
81
+ const r = settled[j];
82
+ const url = batch[j];
83
+ if (r.status === 'fulfilled') {
84
+ const { payload } = r.value;
85
+ if (payload === null) {
86
+ outcomes.push({ url, status: 'unknown_scheme' });
87
+ }
88
+ else {
89
+ outcomes.push({
90
+ url,
91
+ status: 'ok',
92
+ source: payload.source,
93
+ bytes: payload.text.length,
94
+ estimatedTokens: payload.estimatedTokens,
95
+ });
96
+ payloads.push({ url, payload });
97
+ }
98
+ }
99
+ else {
100
+ const err = r.reason;
101
+ const isUnconfigured = /not configured/i.test(err.message);
102
+ outcomes.push({
103
+ url,
104
+ status: isUnconfigured ? 'unconfigured' : 'error',
105
+ detail: err.message,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ const totalEstimatedTokens = payloads.reduce((sum, p) => sum + p.payload.estimatedTokens, 0);
111
+ const enrichedPrompt = buildEnrichedPrompt(prompt, payloads, outcomes);
112
+ return { enrichedPrompt, outcomes, totalEstimatedTokens };
113
+ }
114
+ function buildEnrichedPrompt(prompt, payloads, outcomes) {
115
+ if (payloads.length === 0 && outcomes.every((o) => o.status === 'ok' || o.status === 'unknown_scheme')) {
116
+ // Nothing resolved AND no errors worth reporting · skip the preamble entirely.
117
+ return prompt;
118
+ }
119
+ const lines = [];
120
+ lines.push('--- context resolved by codehere ---');
121
+ lines.push('The user referenced these external resources. Their content is included below for your reference.');
122
+ lines.push('');
123
+ for (const { url, payload } of payloads) {
124
+ lines.push(`[context-source ${payload.source} · ${url}]`);
125
+ lines.push(payload.text);
126
+ lines.push('');
127
+ }
128
+ // Report errors inline so the agent knows what it's missing rather than
129
+ // silently working without it.
130
+ const errors = outcomes.filter((o) => o.status === 'error' || o.status === 'unconfigured');
131
+ if (errors.length) {
132
+ lines.push('--- context-source errors ---');
133
+ for (const e of errors) {
134
+ lines.push(`[${e.status}] ${e.url} · ${e.detail ?? '(no detail)'}`);
135
+ }
136
+ lines.push('');
137
+ }
138
+ lines.push('--- end of context-source preamble ---');
139
+ lines.push('');
140
+ lines.push('User prompt below this line:');
141
+ lines.push(prompt);
142
+ return lines.join('\n');
143
+ }
144
+ //# sourceMappingURL=context-resolver.js.map