@ikarmic/forge-core 5.0.0 → 5.1.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.
Files changed (163) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.d.ts +8 -0
  3. package/dist/agent/agent.js +148 -24
  4. package/dist/agent/background.d.ts +58 -0
  5. package/dist/agent/background.js +139 -0
  6. package/dist/agent/enhance-prompt.d.ts +3 -1
  7. package/dist/agent/enhance-prompt.js +6 -2
  8. package/dist/agent/goal.d.ts +54 -0
  9. package/dist/agent/goal.js +65 -0
  10. package/dist/agent/prompts.d.ts +25 -1
  11. package/dist/agent/prompts.js +61 -17
  12. package/dist/agents/agent-files.d.ts +43 -0
  13. package/dist/agents/agent-files.js +109 -0
  14. package/dist/checkpoints/store.js +5 -1
  15. package/dist/commands/custom-commands.d.ts +9 -4
  16. package/dist/commands/custom-commands.js +13 -2
  17. package/dist/config-file.d.ts +43 -0
  18. package/dist/config-file.js +249 -5
  19. package/dist/config-merge.d.ts +13 -0
  20. package/dist/config-merge.js +67 -0
  21. package/dist/config-schema.d.ts +340 -0
  22. package/dist/config-schema.js +126 -0
  23. package/dist/config.js +2 -1
  24. package/dist/context/compaction.js +4 -0
  25. package/dist/context/mask.js +1 -1
  26. package/dist/custom-providers.d.ts +95 -0
  27. package/dist/custom-providers.js +120 -0
  28. package/dist/git/auto-commit.d.ts +2 -2
  29. package/dist/git/auto-commit.js +5 -4
  30. package/dist/github/github-tools.d.ts +2 -2
  31. package/dist/hooks/runner.js +3 -2
  32. package/dist/import/claude-code.d.ts +39 -0
  33. package/dist/import/claude-code.js +254 -0
  34. package/dist/index.d.ts +34 -5
  35. package/dist/index.js +27 -4
  36. package/dist/journey/answers-block.d.ts +17 -0
  37. package/dist/journey/answers-block.js +15 -0
  38. package/dist/journey/index.d.ts +9 -0
  39. package/dist/journey/index.js +8 -0
  40. package/dist/journey/journey.d.ts +13 -0
  41. package/dist/journey/journey.js +40 -0
  42. package/dist/journey/offer.d.ts +25 -0
  43. package/dist/journey/offer.js +48 -0
  44. package/dist/journey/questions.d.ts +23 -0
  45. package/dist/journey/questions.js +49 -0
  46. package/dist/journey/stages.d.ts +13 -0
  47. package/dist/journey/stages.js +107 -0
  48. package/dist/journey/survey.d.ts +10 -0
  49. package/dist/journey/survey.js +203 -0
  50. package/dist/journey/trigger.d.ts +19 -0
  51. package/dist/journey/trigger.js +79 -0
  52. package/dist/journey/types.d.ts +72 -0
  53. package/dist/journey/types.js +1 -0
  54. package/dist/lsp/client.d.ts +5 -0
  55. package/dist/lsp/client.js +39 -3
  56. package/dist/mcp/index.d.ts +5 -1
  57. package/dist/mcp/index.js +3 -1
  58. package/dist/mcp/manager.d.ts +17 -3
  59. package/dist/mcp/manager.js +27 -2
  60. package/dist/mcp/marketplace.d.ts +2 -0
  61. package/dist/mcp/marketplace.js +10 -4
  62. package/dist/mcp/mcp-client.d.ts +12 -2
  63. package/dist/mcp/mcp-client.js +74 -17
  64. package/dist/mcp/mcp-config.d.ts +2 -0
  65. package/dist/mcp/mcp-config.js +1 -0
  66. package/dist/mcp/oauth.d.ts +55 -0
  67. package/dist/mcp/oauth.js +197 -0
  68. package/dist/mcp/resource-tools.d.ts +8 -0
  69. package/dist/mcp/resource-tools.js +56 -0
  70. package/dist/memory/session-digest.d.ts +12 -0
  71. package/dist/memory/session-digest.js +47 -0
  72. package/dist/memory/stale.js +2 -1
  73. package/dist/model-alias.d.ts +18 -2
  74. package/dist/model-alias.js +27 -3
  75. package/dist/model-catalog.js +0 -1
  76. package/dist/model-list.js +2 -1
  77. package/dist/model-overrides.d.ts +30 -0
  78. package/dist/model-overrides.js +48 -0
  79. package/dist/orchestrator/specialists-registry.js +2 -3
  80. package/dist/orchestrator/specialists.js +4 -4
  81. package/dist/policy/ca-certs.d.ts +8 -0
  82. package/dist/policy/ca-certs.js +30 -0
  83. package/dist/policy/proxy.d.ts +10 -3
  84. package/dist/policy/proxy.js +33 -6
  85. package/dist/pricing.js +4 -0
  86. package/dist/providers.d.ts +7 -0
  87. package/dist/providers.js +39 -5
  88. package/dist/semantic/embedder.d.ts +15 -0
  89. package/dist/semantic/embedder.js +25 -0
  90. package/dist/semantic/semantic-index.d.ts +10 -2
  91. package/dist/semantic/semantic-index.js +16 -7
  92. package/dist/sessions/session-store.js +2 -1
  93. package/dist/skills/builtin.d.ts +2 -0
  94. package/dist/skills/builtin.js +68 -0
  95. package/dist/skills/index.d.ts +2 -0
  96. package/dist/skills/index.js +2 -0
  97. package/dist/skills/loader.d.ts +10 -3
  98. package/dist/skills/loader.js +20 -7
  99. package/dist/skills/marketplace.js +2 -1
  100. package/dist/skills/remote.d.ts +14 -0
  101. package/dist/skills/remote.js +131 -0
  102. package/dist/skills/types.d.ts +10 -0
  103. package/dist/specs/generate.d.ts +18 -9
  104. package/dist/specs/generate.js +174 -14
  105. package/dist/specs/index.d.ts +2 -2
  106. package/dist/specs/index.js +1 -1
  107. package/dist/specs/store.d.ts +14 -2
  108. package/dist/specs/store.js +26 -1
  109. package/dist/specs/types.d.ts +11 -0
  110. package/dist/steering/extra-instructions.d.ts +8 -0
  111. package/dist/steering/extra-instructions.js +104 -0
  112. package/dist/steering/folder-instructions.d.ts +8 -0
  113. package/dist/steering/folder-instructions.js +28 -0
  114. package/dist/steering/select.js +2 -1
  115. package/dist/timed-fetch.d.ts +12 -0
  116. package/dist/timed-fetch.js +83 -0
  117. package/dist/tools/apply-patch.d.ts +52 -0
  118. package/dist/tools/apply-patch.js +197 -0
  119. package/dist/tools/ask-user.d.ts +27 -0
  120. package/dist/tools/ask-user.js +60 -0
  121. package/dist/tools/browser-tools.d.ts +6 -6
  122. package/dist/tools/browser-tools.js +3 -13
  123. package/dist/tools/codebase-search.d.ts +4 -2
  124. package/dist/tools/codebase-search.js +2 -2
  125. package/dist/tools/edit-file.js +5 -3
  126. package/dist/tools/fetch-url.d.ts +7 -0
  127. package/dist/tools/fetch-url.js +52 -19
  128. package/dist/tools/find-symbol.d.ts +2 -2
  129. package/dist/tools/ignore-rules.js +2 -1
  130. package/dist/tools/index.d.ts +5 -1
  131. package/dist/tools/index.js +10 -1
  132. package/dist/tools/memory.d.ts +6 -6
  133. package/dist/tools/notebook.d.ts +2 -2
  134. package/dist/tools/permission-rules.d.ts +32 -0
  135. package/dist/tools/permission-rules.js +182 -0
  136. package/dist/tools/read-file.d.ts +5 -0
  137. package/dist/tools/read-file.js +12 -2
  138. package/dist/tools/read-only.d.ts +1 -1
  139. package/dist/tools/read-only.js +2 -0
  140. package/dist/tools/run-command.d.ts +4 -2
  141. package/dist/tools/run-command.js +12 -8
  142. package/dist/tools/sandbox-loader.js +3 -13
  143. package/dist/tools/search-files.d.ts +8 -2
  144. package/dist/tools/search-files.js +43 -0
  145. package/dist/tools/shared.d.ts +5 -0
  146. package/dist/tools/shared.js +30 -2
  147. package/dist/tools/shell-env.d.ts +7 -0
  148. package/dist/tools/shell-env.js +9 -0
  149. package/dist/tools/sub-agent-wait.d.ts +29 -0
  150. package/dist/tools/sub-agent-wait.js +60 -0
  151. package/dist/tools/sub-agent.d.ts +50 -4
  152. package/dist/tools/sub-agent.js +213 -125
  153. package/dist/tools/text-encoding.d.ts +10 -0
  154. package/dist/tools/text-encoding.js +40 -0
  155. package/dist/tools/todo.d.ts +36 -0
  156. package/dist/tools/todo.js +42 -0
  157. package/dist/tools/write-file.js +15 -9
  158. package/dist/tools/write-tools.d.ts +12 -0
  159. package/dist/tools/write-tools.js +18 -0
  160. package/dist/types.d.ts +98 -0
  161. package/dist/worktree/worktree.d.ts +3 -0
  162. package/dist/worktree/worktree.js +22 -0
  163. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The Survey: what the workspace already answers, read before anyone is asked
3
+ * anything.
4
+ *
5
+ * There is no model here, by design (spec decision 18). A survey fact that a
6
+ * model produced could be hallucinated, and the asymmetry is brutal: a missing
7
+ * fact costs one extra interview question, while a wrong fact is a confident
8
+ * silent guess. Everything below is a file read, each one independently
9
+ * guarded, so an unreadable or hostile workspace yields absent facts rather
10
+ * than wrong ones or an exception.
11
+ *
12
+ * Nothing here decides "greenfield" or "brownfield". It reports facts; the
13
+ * question set is derived from which of them came back empty.
14
+ */
15
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { isOutsideRel } from '../tools/shared.js';
18
+ import { detectProjectChecks } from '../verify/detect.js';
19
+ /** Ceiling on the tree walk, so a monorepo costs the same as a small repo. */
20
+ export const MAX_SURVEY_FILES = 400;
21
+ /** Second ceiling: a tree of mostly empty or non-source directories is bounded too. */
22
+ export const MAX_SURVEY_DIRS = 2000;
23
+ const MANIFESTS = ['package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod', 'pom.xml', 'Gemfile', 'composer.json'];
24
+ const CONVENTION_FILES = ['AGENTS.md', 'CLAUDE.md'];
25
+ const SOURCE_EXT = new Set([
26
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.cs', '.kt', '.swift',
27
+ ]);
28
+ const IGNORE_DIRS = new Set([
29
+ 'node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', 'target', 'vendor', '__pycache__', '.venv',
30
+ ]);
31
+ const STOPWORDS = new Set([
32
+ 'add', 'the', 'and', 'this', 'our', 'for', 'with', 'from', 'into', 'build', 'create', 'make', 'need', 'want',
33
+ 'support', 'existing', 'that', 'them', 'app', 'repo', 'project', 'please', 'also', 'new',
34
+ ]);
35
+ const MAX_RELATED = 20;
36
+ /**
37
+ * True when `name` inside `root` resolves to something still inside `root`.
38
+ * A repository can ship a symlink or junction, so the check resolves links on
39
+ * both sides and tests the relative path with `isOutsideRel` (RB-20).
40
+ */
41
+ function containedIn(root, name) {
42
+ try {
43
+ const realRoot = realpathSync(root);
44
+ const realTarget = realpathSync(path.join(root, name));
45
+ return !isOutsideRel(path.relative(realRoot, realTarget));
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ function firstContainedManifest(cwd) {
52
+ return MANIFESTS.find((m) => containedIn(cwd, m));
53
+ }
54
+ /**
55
+ * History, read rather than run: a ref anywhere under `refs/heads`, or a
56
+ * packed-refs file. Branch names nest (`refs/heads/user/feature/x`), so each
57
+ * directory's children are joined to *that* directory, and a single unreadable
58
+ * directory skips rather than ending the scan.
59
+ */
60
+ function hasCommits(cwd) {
61
+ const git = path.join(cwd, '.git');
62
+ try {
63
+ // A worktree or submodule has `.git` as a file holding `gitdir: …`. Following
64
+ // it would mean parsing a path out of another repo's layout; a worktree only
65
+ // exists for a repo that already has history, so take that as the answer.
66
+ if (statSync(git).isFile())
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ if (existsSync(path.join(git, 'packed-refs')))
73
+ return true;
74
+ const stack = [path.join(git, 'refs', 'heads')];
75
+ while (stack.length > 0) {
76
+ const dir = stack.pop();
77
+ let entries;
78
+ try {
79
+ entries = readdirSync(dir, { withFileTypes: true });
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ for (const e of entries) {
85
+ if (e.isDirectory())
86
+ stack.push(path.join(dir, e.name));
87
+ else
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ /**
94
+ * Repo-relative source paths, breadth-first, bounded, never entering an ignored
95
+ * directory.
96
+ *
97
+ * The directory is tracked explicitly rather than read from the `Dirent`
98
+ * parent-directory property: that one arrived in Node 20.12 / 18.20, and this
99
+ * also runs in the VS Code extension host, which ships older Node across the
100
+ * supported `engines.vscode` range. A fallback there would build paths that do
101
+ * not exist, and the walk would quietly see only the top level — reporting
102
+ * *wrong* facts, which is the one thing this file promises never to do.
103
+ * `journey-survey.test.ts` forbids that identifier here, prose included, since
104
+ * the mistake cannot fail on a developer's newer Node.
105
+ *
106
+ * Directories visited are capped as well as files, so a huge non-source tree
107
+ * outside `IGNORE_DIRS` cannot make this walk unbounded on the editor's thread.
108
+ */
109
+ function sourceFiles(cwd) {
110
+ const out = [];
111
+ const queue = [cwd];
112
+ let visited = 0;
113
+ while (queue.length > 0 && out.length < MAX_SURVEY_FILES && visited < MAX_SURVEY_DIRS) {
114
+ const dir = queue.shift();
115
+ visited += 1;
116
+ let entries;
117
+ try {
118
+ entries = readdirSync(dir, { withFileTypes: true });
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ for (const e of entries) {
124
+ if (out.length >= MAX_SURVEY_FILES)
125
+ break;
126
+ const abs = path.join(dir, e.name);
127
+ if (e.isDirectory()) {
128
+ if (!IGNORE_DIRS.has(e.name))
129
+ queue.push(abs);
130
+ }
131
+ else if (SOURCE_EXT.has(path.extname(e.name))) {
132
+ out.push(path.relative(cwd, abs));
133
+ }
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ /** Source paths whose name mentions a noun from the request. Deterministic, no index needed. */
139
+ function relatedTo(request, files) {
140
+ const tokens = [...new Set((request.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? []).filter((w) => !STOPWORDS.has(w)))];
141
+ if (tokens.length === 0)
142
+ return [];
143
+ const hits = files.filter((f) => {
144
+ const lower = f.toLowerCase();
145
+ return tokens.some((tok) => lower.includes(tok));
146
+ });
147
+ return hits.slice(0, MAX_RELATED);
148
+ }
149
+ function presentFiles(cwd, names) {
150
+ return names.filter((n) => containedIn(cwd, n));
151
+ }
152
+ function priorSpecIds(cwd) {
153
+ try {
154
+ return readdirSync(path.join(cwd, '.forge', 'specs'), { withFileTypes: true })
155
+ .filter((e) => e.isDirectory())
156
+ .map((e) => e.name);
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }
162
+ /**
163
+ * Read what this workspace already says about itself. Never throws: a fact that
164
+ * cannot be read is absent.
165
+ */
166
+ export async function surveyWorkspace(cwd, request) {
167
+ const facts = {
168
+ checks: [],
169
+ isGitRepo: false,
170
+ hasCommits: false,
171
+ sourceFileCount: 0,
172
+ conventionFiles: [],
173
+ relatedSymbols: [],
174
+ priorSpecIds: [],
175
+ };
176
+ const manifest = firstContainedManifest(cwd);
177
+ if (manifest) {
178
+ // Only ask detect.ts about a manifest we have established is inside the
179
+ // workspace, so a symlinked one is never read for its scripts either.
180
+ try {
181
+ const detected = await detectProjectChecks(cwd);
182
+ facts.checks = detected.checks;
183
+ facts.manifest = { path: manifest, ...(detected.packageManager ? { packageManager: detected.packageManager } : {}) };
184
+ }
185
+ catch {
186
+ facts.manifest = { path: manifest };
187
+ }
188
+ }
189
+ try {
190
+ facts.isGitRepo = existsSync(path.join(cwd, '.git'));
191
+ }
192
+ catch {
193
+ facts.isGitRepo = false;
194
+ }
195
+ if (facts.isGitRepo)
196
+ facts.hasCommits = hasCommits(cwd);
197
+ const files = sourceFiles(cwd);
198
+ facts.sourceFileCount = files.length;
199
+ facts.relatedSymbols = relatedTo(request, files);
200
+ facts.conventionFiles = presentFiles(cwd, CONVENTION_FILES);
201
+ facts.priorSpecIds = priorSpecIds(cwd);
202
+ return facts;
203
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tier 1 of the journey trigger: does this message deserve a read-only Survey?
3
+ *
4
+ * It decides whether to *look*, not whether to show a card. A false positive
5
+ * costs a few auto-approved, token-capped reads; a false positive on the card
6
+ * is the nagging that kills the feature (spec anti-goal 1). So this is
7
+ * deliberately generous and the vetoes carry the weight.
8
+ *
9
+ * Signals are noun-driven, not verb-driven: a change verb on its own trips on
10
+ * everything ("add a button"), so it only counts beside a noun that implies
11
+ * many files.
12
+ */
13
+ import type { TriggerVerdict } from './types.js';
14
+ /**
15
+ * `typed` is what the person wrote, `sent` what goes to the model. They differ
16
+ * when a slash command, skill or `@agent` mention expanded into a long body;
17
+ * judging that text would offer a journey for a one-line request (RB-16).
18
+ */
19
+ export declare function shouldSurvey(typed: string, sent: string): TriggerVerdict;
@@ -0,0 +1,79 @@
1
+ const BUILD_VERBS = /\b(?:build|create|make|develop|scaffold|set up|generate|write me)\b/i;
2
+ const CHANGE_VERBS = /\b(?:add|extend|integrate|migrate|refactor|support|introduce|replace|rework|wire up|hook up|implement)\b/i;
3
+ const SYSTEM_NOUNS = /\b(?:app|application|system|platform|crm|erp|cms|web ?site|api|backend|frontend|dashboard|portal|saas|marketplace|game)\b/i;
4
+ /** Nouns that imply many files rather than one element. */
5
+ const SCALE_NOUNS = /\b(?:module|subsystem|service|integration|pipeline|workflow|migration|multi-?tenancy|auth|authentication|authorisation|authorization|sso|oauth|billing|payments|notifications|search|reporting|analytics|audit log|admin panel|permissions|rbac|onboarding|checkout)\b/i;
6
+ /** Hard vetoes: the person has already told us the scope is small. */
7
+ const SMALL_VERBS = /\b(?:fix|typo|rename|bump|tweak|format|indent|lint|spelling|revert)\b/i;
8
+ const UI_ATOM = /\b(?:button|label|icon|tooltip|placeholder|colour|color|margin|padding|border|font)\b/i;
9
+ const CROSS_CUTTING = /\b(?:across|everywhere|all our|end-to-end|throughout|every (?:page|screen|endpoint))\b/i;
10
+ const PLURAL_DELIVERABLE = /\b(?:pages|endpoints|models|screens|tables|services|roles|reports)\b/i;
11
+ const REQUIREMENT_INTENT = /\b(?:need|want|should|must|would like|let (?:people|users|them|me))\b/i;
12
+ const LONGISH_WORDS = 25;
13
+ const MIN_LIST_SEPARATORS = 3;
14
+ /** Cap before any regex runs, so a pathological message cannot stall the editor. */
15
+ const MAX_INPUT = 4000;
16
+ /**
17
+ * Technology names that are shaped exactly like filenames. Without this,
18
+ * "Build me a Next.js SaaS platform" — close to the canonical phrasing of the
19
+ * flagship scenario — is vetoed as naming a file and silently gets nothing.
20
+ */
21
+ const TECH_TOKENS = /\b(?:next|node|nuxt|vue|three|d3|express|react|nest|ember|backbone|socket|chart|moment|day)\.(?:js|io|ts)\b/gi;
22
+ /**
23
+ * A file path or extension means a scoped change, however big the noun beside it.
24
+ *
25
+ * Two narrowings against the version in `vscode/src/chat/large-request.ts`:
26
+ * technology names are removed first, and the extension must be letters, so a
27
+ * version number ("the 2.0 release") is not a filename.
28
+ */
29
+ function namesAFile(text) {
30
+ const withoutTech = text.replace(TECH_TOKENS, ' ');
31
+ return /[\\/]|\.[a-z]{1,5}\b/i.test(withoutTech.replace(/[.!?]+(?=\s|$)/g, ''));
32
+ }
33
+ function listSeparators(text) {
34
+ const commasAndAnds = (text.match(/,|\band\b/gi) ?? []).length;
35
+ const bullets = (text.match(/^\s*(?:[-*•]|\d+[.)])\s/gm) ?? []).length;
36
+ return commasAndAnds + bullets;
37
+ }
38
+ /**
39
+ * `typed` is what the person wrote, `sent` what goes to the model. They differ
40
+ * when a slash command, skill or `@agent` mention expanded into a long body;
41
+ * judging that text would offer a journey for a one-line request (RB-16).
42
+ */
43
+ export function shouldSurvey(typed, sent) {
44
+ const full = typed.trim();
45
+ if (!full)
46
+ return { survey: false, reasons: ['empty'] };
47
+ if (full !== sent.trim())
48
+ return { survey: false, reasons: ['expanded command'] };
49
+ if (/^\/\w/.test(full))
50
+ return { survey: false, reasons: ['slash command'] };
51
+ if (/(?:^|\s)@\S/.test(full))
52
+ return { survey: false, reasons: ['@agent mention'] };
53
+ const t = full.slice(0, MAX_INPUT);
54
+ if (namesAFile(t))
55
+ return { survey: false, reasons: ['veto: names a file or path'] };
56
+ if (SMALL_VERBS.test(t))
57
+ return { survey: false, reasons: ['veto: small-change verb'] };
58
+ const reasons = [];
59
+ const change = CHANGE_VERBS.test(t);
60
+ if (BUILD_VERBS.test(t) && SYSTEM_NOUNS.test(t))
61
+ reasons.push('build verb + system noun');
62
+ if (change && SCALE_NOUNS.test(t))
63
+ reasons.push('change verb + scale noun');
64
+ if (change && SYSTEM_NOUNS.test(t))
65
+ reasons.push('change verb + system noun');
66
+ if (CROSS_CUTTING.test(t))
67
+ reasons.push('cross-cutting scope');
68
+ if (change && PLURAL_DELIVERABLE.test(t))
69
+ reasons.push('plural deliverables');
70
+ if (t.split(/\s+/).length >= LONGISH_WORDS && REQUIREMENT_INTENT.test(t) && listSeparators(t) >= MIN_LIST_SEPARATORS) {
71
+ reasons.push('long multi-requirement message');
72
+ }
73
+ if (reasons.length === 0)
74
+ return { survey: false, reasons: ['no scope signal'] };
75
+ // One UI element with a single weak signal is a scoped edit, not a subsystem.
76
+ if (UI_ATOM.test(t) && reasons.length < 2)
77
+ return { survey: false, reasons: ['veto: single UI element'] };
78
+ return { survey: true, reasons };
79
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared types for the guided journey. Types only, no logic, so every other
3
+ * file in `journey/` can import this one without a cycle.
4
+ *
5
+ * Design: docs/superpowers/specs/2026-10-23-guided-journey-design.md (rev 3)
6
+ */
7
+ import type { DetectedCheck } from '../verify/detect.js';
8
+ /** Tier-1 verdict: whether a read-only Survey is worth running, and why. */
9
+ export interface TriggerVerdict {
10
+ survey: boolean;
11
+ /** Human-readable signals or vetoes; the offer card shows these. */
12
+ reasons: string[];
13
+ }
14
+ /**
15
+ * What the Survey read. Every field is absent or empty when it was not read —
16
+ * never guessed. There is deliberately no greenfield/brownfield flag: a
17
+ * workspace that is half-scaffolded is neither, and a label would have to lie
18
+ * about it. `journey-questions.test.ts` forbids that identifier anywhere under
19
+ * `src/`, prose included, which is why this note spells it out in words.
20
+ */
21
+ export interface SurveyFacts {
22
+ manifest?: {
23
+ path: string;
24
+ packageManager?: string;
25
+ };
26
+ checks: DetectedCheck[];
27
+ isGitRepo: boolean;
28
+ hasCommits: boolean;
29
+ sourceFileCount: number;
30
+ conventionFiles: string[];
31
+ relatedSymbols: string[];
32
+ priorSpecIds: string[];
33
+ }
34
+ /** Tier-2 decision, with the evidence that earns the card. */
35
+ export interface JourneyOffer {
36
+ offer: boolean;
37
+ evidence: string[];
38
+ }
39
+ /**
40
+ * One interview question. `answeredBy` is the precondition: when it returns a
41
+ * value, the Survey already answered this and the question is never asked.
42
+ */
43
+ export interface InterviewQuestion {
44
+ id: string;
45
+ question: string;
46
+ options?: string[];
47
+ multiSelect?: boolean;
48
+ answeredBy: (facts: SurveyFacts) => string | undefined;
49
+ }
50
+ /** A question the person answered, carried into the system prompt. */
51
+ export interface Answer {
52
+ id: string;
53
+ question: string;
54
+ answer: string;
55
+ }
56
+ export type StageId = 'survey' | 'interview' | 'plan' | 'spec' | 'implPlan' | 'build';
57
+ export interface Stage {
58
+ id: StageId;
59
+ /** Survey and Interview are host-driven and run no model at all. */
60
+ usesModel: boolean;
61
+ promptSuffix: string;
62
+ allowTools: readonly string[];
63
+ artefact: string;
64
+ gateOptions: readonly string[];
65
+ }
66
+ export interface JourneyState {
67
+ /** `'done'` once the journey is over, including when a saved stage id is unknown. */
68
+ current: StageId | 'done';
69
+ answers: Answer[];
70
+ /** Index into the approved `tasks.md` during the Build stage. */
71
+ taskCursor: number;
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The absolute path of `command` found on PATH (with PATHEXT on Windows), or undefined. Never the current directory.
3
+ * A command that already names a path is returned as it is.
4
+ */
5
+ export declare function resolveOnPath(command: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | undefined;
1
6
  export type LspSeverity = 'error' | 'warning' | 'info' | 'hint';
2
7
  export interface LspDiagnostic {
3
8
  /** 1-based. */
@@ -4,6 +4,37 @@
4
4
  * JSON-RPC 2.0 with `Content-Length` framing is small enough to own.
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
+ import { statSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ /**
10
+ * The absolute path of `command` found on PATH (with PATHEXT on Windows), or undefined. Never the current directory.
11
+ * A command that already names a path is returned as it is.
12
+ */
13
+ export function resolveOnPath(command, env, platform) {
14
+ if (/[\\/]/.test(command))
15
+ return command;
16
+ const isFile = (p) => {
17
+ try {
18
+ return statSync(p).isFile();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ };
24
+ const win = platform === 'win32';
25
+ const dirs = (env.PATH ?? env.Path ?? '').split(win ? ';' : path.delimiter).filter(Boolean);
26
+ const exts = win ? ['', ...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase())] : [''];
27
+ for (const dir of dirs) {
28
+ for (const ext of exts) {
29
+ if (win && ext === '' && !/\.[a-z0-9]+$/i.test(command))
30
+ continue; // Windows runs nothing without an extension
31
+ const candidate = path.join(dir, command + ext);
32
+ if (isFile(candidate))
33
+ return candidate;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
7
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
39
  /**
9
40
  * Servers echo document URIs in their own spelling (`file:///c%3A/x` for the
@@ -45,9 +76,14 @@ export class LspClient {
45
76
  * the spawn error (e.g. ENOENT) when the server binary is not installed.
46
77
  */
47
78
  static async start(spec, rootDir, timeoutMs) {
48
- // Bare names on Windows are usually npm `.cmd` shims, which need a shell; explicit paths must not go through one.
49
- const shell = process.platform === 'win32' && !/[\\/]/.test(spec.command);
50
- const proc = spawn(spec.command, spec.args ?? [], { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'], shell });
79
+ // The server runs in the workspace, and Windows looks in the current directory before PATH: a repository could
80
+ // ship its own `typescript-language-server.cmd`. So a bare name is resolved on PATH first, never in the workspace.
81
+ const command = resolveOnPath(spec.command, process.env, process.platform);
82
+ if (!command)
83
+ throw Object.assign(new Error(`${spec.command} is not installed (not found on PATH)`), { code: 'ENOENT' });
84
+ // npm `.cmd` shims need a shell on Windows; the path is quoted because it is now absolute and may contain spaces.
85
+ const shell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
86
+ const proc = spawn(shell ? `"${command}"` : command, spec.args ?? [], { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'], shell });
51
87
  const spawned = new Promise((_, reject) => proc.once('error', reject));
52
88
  const client = new LspClient(proc);
53
89
  const rootUri = pathToFileURL(rootDir).href;
@@ -1,7 +1,11 @@
1
1
  export { loadMcpConfig, parseMcpConfig } from './mcp-config.js';
2
2
  export type { McpConfigFile, McpServerSpec } from './mcp-config.js';
3
3
  export { jsonSchemaToZod } from './json-schema-to-zod.js';
4
- export { connectAll, connectOne, closeAll, mcpResultToValue } from './mcp-client.js';
4
+ export { connectAll, connectOne, closeAll, mcpInstructionsBlock, mcpResultToValue, McpAuthRequiredError } from './mcp-client.js';
5
+ export type { ConnectOptions } from './mcp-client.js';
6
+ export { forgetSavedAuth, defaultMcpAuthDir } from './oauth.js';
7
+ export type { McpOAuthOptions } from './oauth.js';
8
+ export { createMcpResourceTools } from './resource-tools.js';
5
9
  export { BUILTIN_MCP_CATALOG, loadMcpCatalog } from './catalog.js';
6
10
  export { McpManager, expandSpec } from './manager.js';
7
11
  export type { McpManagerOptions, McpServerState, McpServerStatus } from './manager.js';
package/dist/mcp/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { loadMcpConfig, parseMcpConfig } from './mcp-config.js';
2
2
  export { jsonSchemaToZod } from './json-schema-to-zod.js';
3
- export { connectAll, connectOne, closeAll, mcpResultToValue } from './mcp-client.js';
3
+ export { connectAll, connectOne, closeAll, mcpInstructionsBlock, mcpResultToValue, McpAuthRequiredError } from './mcp-client.js';
4
+ export { forgetSavedAuth, defaultMcpAuthDir } from './oauth.js';
5
+ export { createMcpResourceTools } from './resource-tools.js';
4
6
  export { BUILTIN_MCP_CATALOG, loadMcpCatalog } from './catalog.js';
5
7
  export { McpManager, expandSpec } from './manager.js';
6
8
  export { assertValidServerName, registerMcpServer, setMcpServerDisabled } from './registry.js';
@@ -1,8 +1,9 @@
1
1
  import type { ToolDefinition } from '../types.js';
2
- import { type ConnectedMcpServer } from './mcp-client.js';
2
+ import { type ConnectOptions, type ConnectedMcpServer } from './mcp-client.js';
3
+ import type { McpOAuthOptions } from './oauth.js';
3
4
  import type { McpServerSpec } from './mcp-config.js';
4
5
  import { type OrgPolicy } from '../policy/policy.js';
5
- export type McpServerState = 'connected' | 'failed' | 'disabled';
6
+ export type McpServerState = 'connected' | 'failed' | 'disabled' | 'needs-auth';
6
7
  export interface McpServerStatus {
7
8
  name: string;
8
9
  state: McpServerState;
@@ -24,8 +25,13 @@ export interface McpManagerOptions {
24
25
  connectTimeoutMs?: number;
25
26
  /** Organization policy for the MCP allowlist. Unset = the managed policy file; null = none. */
26
27
  policy?: OrgPolicy | null;
28
+ /**
29
+ * OAuth sign-in for HTTP servers that want it. Without it such a server is simply "failed" (401). With it, saved
30
+ * tokens are used and a server without them is listed as "needs-auth" until {@link McpManager.signIn} runs.
31
+ */
32
+ oauth?: Omit<McpOAuthOptions, 'interactive'>;
27
33
  /** Test seam. */
28
- connect?: (name: string, spec: McpServerSpec) => Promise<ConnectedMcpServer>;
34
+ connect?: (name: string, spec: McpServerSpec, options?: ConnectOptions) => Promise<ConnectedMcpServer>;
29
35
  }
30
36
  /** Replace `${workspaceFolder}` in args, env values, url and headers. */
31
37
  export declare function expandSpec(spec: McpServerSpec, cwd: string): McpServerSpec;
@@ -41,8 +47,16 @@ export declare class McpManager {
41
47
  ensureConnected(): Promise<void>;
42
48
  /** Tools from all connected servers (call {@link ensureConnected} first). */
43
49
  tools(): ToolDefinition[];
50
+ /** The servers that are connected right now (call {@link ensureConnected} first). */
51
+ servers(): readonly ConnectedMcpServer[];
44
52
  status(): McpServerStatus[];
45
53
  /** Drop connections and re-read the config files. */
54
+ private readonly specs;
55
+ /**
56
+ * Sign in to a server listed as `needs-auth`: opens the user's browser, waits for them to finish (up to the sign-in
57
+ * timeout), saves the tokens, then reconnects everything so the server's tools appear.
58
+ */
59
+ signIn(name: string): Promise<void>;
46
60
  reload(): Promise<void>;
47
61
  dispose(): Promise<void>;
48
62
  private readScope;
@@ -5,7 +5,7 @@
5
5
  * reported in `status()` instead of only written to stderr.
6
6
  */
7
7
  import path from 'node:path';
8
- import { closeAll, connectOne } from './mcp-client.js';
8
+ import { McpAuthRequiredError, closeAll, connectOne } from './mcp-client.js';
9
9
  import { loadMcpConfig } from './mcp-config.js';
10
10
  import { defaultUserMcpPath } from './marketplace.js';
11
11
  import { filterMcpServers, getOrgPolicy } from '../policy/policy.js';
@@ -50,10 +50,32 @@ export class McpManager {
50
50
  tools() {
51
51
  return this.connected.flatMap((s) => s.tools);
52
52
  }
53
+ /** The servers that are connected right now (call {@link ensureConnected} first). */
54
+ servers() {
55
+ return this.connected;
56
+ }
53
57
  status() {
54
58
  return [...this.statuses];
55
59
  }
56
60
  /** Drop connections and re-read the config files. */
61
+ specs = new Map();
62
+ /**
63
+ * Sign in to a server listed as `needs-auth`: opens the user's browser, waits for them to finish (up to the sign-in
64
+ * timeout), saves the tokens, then reconnects everything so the server's tools appear.
65
+ */
66
+ async signIn(name) {
67
+ const oauth = this.options.oauth;
68
+ const spec = this.specs.get(name);
69
+ if (!oauth)
70
+ throw new Error('sign-in is not available here');
71
+ if (!spec)
72
+ throw new Error(`no MCP server named "${name}"`);
73
+ const connect = this.options.connect ?? connectOne;
74
+ const wait = oauth.signInTimeoutMs ?? 5 * 60_000;
75
+ const server = await withTimeout(connect(name, expandSpec(spec, this.options.cwd), { oauth: { ...oauth, interactive: true } }), wait + (this.options.connectTimeoutMs ?? 30_000), `Signing in to MCP server "${name}"`);
76
+ await closeAll([server]);
77
+ await this.reload();
78
+ }
57
79
  async reload() {
58
80
  await this.dispose();
59
81
  await this.ensureConnected();
@@ -110,10 +132,13 @@ export class McpManager {
110
132
  if (spec.disabled)
111
133
  return { status: { name, state: 'disabled', scope, toolCount: 0 } };
112
134
  try {
113
- const server = await withTimeout(connect(name, expandSpec(spec, cwd)), timeoutMs, `MCP server "${name}"`);
135
+ this.specs.set(name, spec);
136
+ const server = await withTimeout(connect(name, expandSpec(spec, cwd), this.options.oauth ? { oauth: { ...this.options.oauth, interactive: false } } : undefined), timeoutMs, `MCP server "${name}"`);
114
137
  return { server, status: { name, state: 'connected', scope, toolCount: server.tools.length } };
115
138
  }
116
139
  catch (err) {
140
+ if (err instanceof McpAuthRequiredError)
141
+ return { status: { name, state: 'needs-auth', scope, toolCount: 0, error: err.message } };
117
142
  return { status: { name, state: 'failed', scope, toolCount: 0, error: err.message } };
118
143
  }
119
144
  }));
@@ -55,6 +55,8 @@ export declare function installDirNameFor(source: {
55
55
  * server" message instead of silently registering nothing.
56
56
  */
57
57
  export declare function probeRepoForServer(repoPath: string, subdir?: string): Promise<McpServerSpec>;
58
+ /** `--` ends git's option parsing: a source that starts with `-` (`--upload-pack=…`) must never be read as an option. */
59
+ export declare function gitCloneArgs(url: string, target: string): string[];
58
60
  /**
59
61
  * Install an MCP server from a catalog entry or a raw GitHub URL.
60
62
  * Returns the {@link InstalledMcpServer} describing what was installed.
@@ -27,6 +27,8 @@ import { homedir } from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { promisify } from 'node:util';
29
29
  import { parseMcpConfig } from './mcp-config.js';
30
+ import { isOutsideRel } from '../tools/shared.js';
31
+ import { updateJsonFile } from '../config-file.js';
30
32
  const execFileP = promisify(execFile);
31
33
  /** Path to the cache root (caller-controlled so tests can inject a temp dir). */
32
34
  export function defaultCacheRoot() {
@@ -152,10 +154,14 @@ export async function probeRepoForServer(repoPath, subdir) {
152
154
  }
153
155
  /** Side-effect: clone the source into the cache dir. */
154
156
  async function gitClone(url, target) {
155
- await execFileP('git', ['clone', '--depth', '1', '--quiet', url, target], {
157
+ await execFileP('git', gitCloneArgs(url, target), {
156
158
  windowsHide: true,
157
159
  });
158
160
  }
161
+ /** `--` ends git's option parsing: a source that starts with `-` (`--upload-pack=…`) must never be read as an option. */
162
+ export function gitCloneArgs(url, target) {
163
+ return ['clone', '--depth', '1', '--quiet', '--', url, target];
164
+ }
159
165
  /**
160
166
  * Install an MCP server from a catalog entry or a raw GitHub URL.
161
167
  * Returns the {@link InstalledMcpServer} describing what was installed.
@@ -197,7 +203,7 @@ export async function installMcpServer(params) {
197
203
  // consequences of a bypass (arbitrary recursive delete) are severe.
198
204
  const installPath = path.join(cacheRoot, name);
199
205
  const relInstall = path.relative(cacheRoot, installPath);
200
- if (relInstall.startsWith('..') || path.isAbsolute(relInstall)) {
206
+ if (isOutsideRel(relInstall)) {
201
207
  throw new Error(`Refusing to install "${name}": resolved path escapes the cache root.`);
202
208
  }
203
209
  if (existsSync(installPath)) {
@@ -260,8 +266,8 @@ export async function readUserMcpRegistry(userMcpPath) {
260
266
  }
261
267
  /** Write the user's MCP registry, creating parent directories as needed. */
262
268
  export async function writeUserMcpRegistry(userMcpPath, config) {
263
- await mkdir(path.dirname(userMcpPath), { recursive: true });
264
- await writeFile(userMcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
269
+ // Atomic, and one writer at a time (see updateJsonFile).
270
+ await updateJsonFile(userMcpPath, () => config);
265
271
  }
266
272
  /** Remove an installed MCP server by name. Idempotent: succeeds even when absent. */
267
273
  export async function uninstallMcpServer(params) {
@@ -1,4 +1,5 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { type McpOAuthOptions } from './oauth.js';
2
3
  import type { McpConfigFile, McpServerSpec } from './mcp-config.js';
3
4
  import type { ToolDefinition } from '../types.js';
4
5
  /**
@@ -9,11 +10,20 @@ export interface ConnectedMcpServer {
9
10
  name: string;
10
11
  client: Client;
11
12
  tools: ToolDefinition[];
13
+ /** What the server says about using it (from `initialize`); shown to the model next to its tools. */
14
+ instructions?: string | undefined;
12
15
  }
13
16
  /** Connect to every server in a parsed MCP config. Failed connections are skipped with a warning. */
14
- export declare function connectAll(config: McpConfigFile): Promise<ConnectedMcpServer[]>;
17
+ export declare function connectAll(config: McpConfigFile, options?: ConnectOptions): Promise<ConnectedMcpServer[]>;
18
+ export { McpAuthRequiredError } from './oauth.js';
19
+ export interface ConnectOptions {
20
+ /** Let an HTTP server that wants OAuth sign-in use saved tokens, and (with `interactive`) ask the user to sign in. */
21
+ oauth?: McpOAuthOptions;
22
+ }
15
23
  /** Connect to a single server and pull its tools. */
16
- export declare function connectOne(name: string, spec: McpServerSpec): Promise<ConnectedMcpServer>;
24
+ export declare function connectOne(name: string, spec: McpServerSpec, options?: ConnectOptions): Promise<ConnectedMcpServer>;
25
+ /** The instructions servers sent with `initialize`, as one prompt block (empty when none sent any). */
26
+ export declare function mcpInstructionsBlock(servers: readonly ConnectedMcpServer[]): string;
17
27
  /** Close all clients and their underlying transports. */
18
28
  export declare function closeAll(servers: ConnectedMcpServer[]): Promise<void>;
19
29
  /**