@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
@@ -6,6 +6,8 @@
6
6
  * internal directory layout.
7
7
  */
8
8
  export type { Skill, SkillRegistry, SkillRoots, SkillSource } from './types.js';
9
+ export { builtinSkills } from './builtin.js';
10
+ export { loadRemoteSkills } from './remote.js';
9
11
  export { GLOBAL_SKILLS_DIR, LOCAL_SKILLS_DIR, PROJECT_SKILLS_DIR, discoverSkills, loadSkillsRegistry, loadSkillsRegistryAsync, parseSkillsDir, resolveSkillRoots, skillsRegistryFromList, } from './loader.js';
10
12
  export { parseSkill } from './parser.js';
11
13
  export { buildSkillsBlock, composeSkillsPrompt, scoreSkill, selectSkills, } from './activator.js';
@@ -5,6 +5,8 @@
5
5
  * callers can use skills + the community catalog without knowing the
6
6
  * internal directory layout.
7
7
  */
8
+ export { builtinSkills } from './builtin.js';
9
+ export { loadRemoteSkills } from './remote.js';
8
10
  export { GLOBAL_SKILLS_DIR, LOCAL_SKILLS_DIR, PROJECT_SKILLS_DIR, discoverSkills, loadSkillsRegistry, loadSkillsRegistryAsync, parseSkillsDir, resolveSkillRoots, skillsRegistryFromList, } from './loader.js';
9
11
  export { parseSkill } from './parser.js';
10
12
  export { buildSkillsBlock, composeSkillsPrompt, scoreSkill, selectSkills, } from './activator.js';
@@ -6,6 +6,7 @@
6
6
  * root wins (local > project > global). The result is an immutable
7
7
  * snapshot: call {@link reload} to re-read disk.
8
8
  */
9
+ import { type RemoteSkillOptions } from './remote.js';
9
10
  import type { Skill, SkillRegistry, SkillRoots, SkillSource } from './types.js';
10
11
  /** Local skill root: `<cwd>/skills/`. Lives next to the project's source so it can be checked into git. */
11
12
  export declare const LOCAL_SKILLS_DIR = "skills";
@@ -31,7 +32,9 @@ export declare function parseSkillsDir(dir: string, kind: SkillSource['kind']):
31
32
  * Discover all skills visible from `cwd`. Walks local → project → global,
32
33
  * merges, and resolves duplicates by precedence (first-wins).
33
34
  */
34
- export declare function discoverSkills(cwd: string): Promise<Skill[]>;
35
+ export declare function discoverSkills(cwd: string, extraDirs?: readonly string[], remote?: RemoteSkillOptions & {
36
+ onWarning?: (warning: string) => void;
37
+ }): Promise<Skill[]>;
35
38
  /**
36
39
  * Build an immutable registry from a pre-loaded skill list. Use this when
37
40
  * tests want to inject a known set without touching the filesystem. Use
@@ -42,9 +45,13 @@ export declare function skillsRegistryFromList(skills: Skill[], roots: SkillRoot
42
45
  * Build a registry by discovering skills from disk relative to `cwd`.
43
46
  * The registry caches its initial load; call `reload()` to refresh.
44
47
  */
45
- export declare function loadSkillsRegistry(cwd: string): SkillRegistry;
48
+ export declare function loadSkillsRegistry(cwd: string, extraDirs?: readonly string[], remote?: RemoteSkillOptions & {
49
+ onWarning?: (warning: string) => void;
50
+ }): SkillRegistry;
46
51
  /**
47
52
  * Async variant: build a registry and immediately load its initial contents.
48
53
  * Useful for callers that want a fully-populated registry in one step.
49
54
  */
50
- export declare function loadSkillsRegistryAsync(cwd: string): Promise<SkillRegistry>;
55
+ export declare function loadSkillsRegistryAsync(cwd: string, extraDirs?: readonly string[], remote?: RemoteSkillOptions & {
56
+ onWarning?: (warning: string) => void;
57
+ }): Promise<SkillRegistry>;
@@ -11,6 +11,8 @@ import { readdir, readFile } from 'node:fs/promises';
11
11
  import { homedir } from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { discoverPlugins } from '../plugins/loader.js';
14
+ import { builtinSkills } from './builtin.js';
15
+ import { isHttpUrl, loadRemoteSkills } from './remote.js';
14
16
  import { parseSkill } from './parser.js';
15
17
  /** Local skill root: `<cwd>/skills/`. Lives next to the project's source so it can be checked into git. */
16
18
  export const LOCAL_SKILLS_DIR = 'skills';
@@ -61,19 +63,30 @@ export async function parseSkillsDir(dir, kind) {
61
63
  * Discover all skills visible from `cwd`. Walks local → project → global,
62
64
  * merges, and resolves duplicates by precedence (first-wins).
63
65
  */
64
- export async function discoverSkills(cwd) {
66
+ export async function discoverSkills(cwd, extraDirs = [], remote = {}) {
65
67
  const roots = resolveSkillRoots(cwd);
66
68
  const [localSkills, projectSkills, globalSkills] = await Promise.all([
67
69
  parseSkillsDir(roots.local, 'local'),
68
70
  parseSkillsDir(roots.project, 'project'),
69
71
  parseSkillsDir(roots.global, 'global'),
70
72
  ]);
73
+ // Folders named in the `skillPaths` setting rank after the user's own folders and before plugins.
74
+ // An entry that is an http(s) URL is fetched (one skill file, or an index of them); the rest are folders.
75
+ const extraSkills = (await Promise.all(extraDirs.map(async (d) => {
76
+ if (isHttpUrl(d)) {
77
+ const got = await loadRemoteSkills(d, remote);
78
+ for (const w of got.warnings)
79
+ remote.onWarning?.(w);
80
+ return got.skills;
81
+ }
82
+ return parseSkillsDir(path.resolve(cwd, d.startsWith('~') ? path.join(homedir(), d.slice(1)) : d), 'global');
83
+ }))).flat();
71
84
  // Plugin skills rank below the user's own: they never shadow a skill written locally.
72
85
  const pluginSkills = (await Promise.all((await discoverPlugins(cwd)).map((p) => (p.skillsDir ? parseSkillsDir(p.skillsDir, p.scope) : Promise.resolve([]))))).flat();
73
- // Order matters: first to claim a name wins. local > project > global > plugins.
86
+ // Order matters: first to claim a name wins. local > project > global > plugins > built in.
74
87
  const seen = new Set();
75
88
  const merged = [];
76
- for (const list of [localSkills, projectSkills, globalSkills, pluginSkills]) {
89
+ for (const list of [localSkills, projectSkills, globalSkills, extraSkills, pluginSkills, builtinSkills()]) {
77
90
  for (const skill of list) {
78
91
  if (seen.has(skill.name))
79
92
  continue;
@@ -105,11 +118,11 @@ export function skillsRegistryFromList(skills, roots) {
105
118
  * Build a registry by discovering skills from disk relative to `cwd`.
106
119
  * The registry caches its initial load; call `reload()` to refresh.
107
120
  */
108
- export function loadSkillsRegistry(cwd) {
121
+ export function loadSkillsRegistry(cwd, extraDirs = [], remote = {}) {
109
122
  const roots = resolveSkillRoots(cwd);
110
123
  let cache = null;
111
124
  const refresh = async () => {
112
- cache = await discoverSkills(cwd);
125
+ cache = await discoverSkills(cwd, extraDirs, remote);
113
126
  return cache;
114
127
  };
115
128
  return {
@@ -123,8 +136,8 @@ export function loadSkillsRegistry(cwd) {
123
136
  * Async variant: build a registry and immediately load its initial contents.
124
137
  * Useful for callers that want a fully-populated registry in one step.
125
138
  */
126
- export async function loadSkillsRegistryAsync(cwd) {
127
- const registry = loadSkillsRegistry(cwd);
139
+ export async function loadSkillsRegistryAsync(cwd, extraDirs = [], remote = {}) {
140
+ const registry = loadSkillsRegistry(cwd, extraDirs, remote);
128
141
  await registry.reload();
129
142
  return registry;
130
143
  }
@@ -11,6 +11,7 @@
11
11
  import { normalizeGithubSource } from '../mcp/marketplace.js';
12
12
  import { parseSkill } from './parser.js';
13
13
  import { DEFAULT_SKILL_CATALOG_URL, SKILL_CATALOG_VERSION, } from './marketplace-types.js';
14
+ import { isOutsideRel } from '../tools/shared.js';
14
15
  /**
15
16
  * Sanitize a catalog-provided name to a safe filename charset before it is
16
17
  * used to build a path on disk. Mirrors `installDirNameFor`'s sanitization
@@ -168,7 +169,7 @@ export async function installSkillFromCatalog(params) {
168
169
  const filePath = path.join(dest, `${safeName}.md`);
169
170
  const resolvedDest = path.resolve(dest);
170
171
  const rel = path.relative(resolvedDest, path.resolve(filePath));
171
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
172
+ if (isOutsideRel(rel)) {
172
173
  return null;
173
174
  }
174
175
  await fs.writeFile(filePath, rawContent, 'utf8');
@@ -0,0 +1,14 @@
1
+ import type { Skill } from './types.js';
2
+ export interface RemoteSkillOptions {
3
+ fetch?: typeof fetch;
4
+ /** Defaults to `~/.forge/skill-cache`. */
5
+ cacheDir?: string;
6
+ ttlMs?: number;
7
+ now?: () => number;
8
+ }
9
+ export interface RemoteSkills {
10
+ skills: Skill[];
11
+ warnings: string[];
12
+ }
13
+ export declare const isHttpUrl: (s: string) => boolean;
14
+ export declare function loadRemoteSkills(url: string, options?: RemoteSkillOptions): Promise<RemoteSkills>;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Skills from a URL: either one skill file (Markdown with front matter) or an index,
3
+ * `{ "skills": [{ "url": "a.md" }, ...] }`, whose links may be relative to the index.
4
+ * Responses are cached on disk so a skill still works offline and a slow host is not asked on every run:
5
+ * a copy younger than the TTL is used as is; an older one is refreshed, and kept if the refresh fails.
6
+ * Anything that times out or is not a skill is skipped with one warning. Only http and https are fetched.
7
+ * The `skillPaths` setting that lists URLs is read from the user's config only.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { homedir } from 'node:os';
12
+ import path from 'node:path';
13
+ import { parseSkill } from './parser.js';
14
+ const FETCH_TIMEOUT_MS = 10_000;
15
+ const MAX_CHARS = 200_000;
16
+ const MAX_INDEX_ENTRIES = 20;
17
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
18
+ /** A host that failed is not asked again for this long, so a dead URL does not add its timeout to every message. */
19
+ const RETRY_AFTER_FAILURE_MS = 5 * 60 * 1000;
20
+ const failures = new Map();
21
+ export const isHttpUrl = (s) => /^https?:\/\//i.test(s);
22
+ function cacheFile(dir, url) {
23
+ return path.join(dir, `${createHash('sha256').update(url).digest('hex').slice(0, 32)}.json`);
24
+ }
25
+ async function readCache(dir, url) {
26
+ try {
27
+ const entry = JSON.parse(await readFile(cacheFile(dir, url), 'utf8'));
28
+ return typeof entry.text === 'string' && typeof entry.fetchedAt === 'number' ? entry : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** The text at `url`: a fresh cached copy, else the network, else a stale cached copy; throws if there is none. */
35
+ async function fetchText(url, opts) {
36
+ const cached = await readCache(opts.cacheDir, url);
37
+ if (cached && opts.now() - cached.fetchedAt < opts.ttlMs)
38
+ return cached.text;
39
+ const failKey = `${opts.cacheDir}|${url}`;
40
+ const failed = failures.get(failKey);
41
+ const since = failed ? opts.now() - failed.at : 0;
42
+ if (failed && since >= 0 && since < RETRY_AFTER_FAILURE_MS) {
43
+ if (cached)
44
+ return cached.text;
45
+ throw new Error(failed.message);
46
+ }
47
+ try {
48
+ const res = await opts.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
49
+ if (!res.ok)
50
+ throw new Error(`HTTP ${res.status}`);
51
+ const text = (await res.text()).slice(0, MAX_CHARS);
52
+ try {
53
+ await mkdir(opts.cacheDir, { recursive: true });
54
+ await writeFile(cacheFile(opts.cacheDir, url), JSON.stringify({ url, fetchedAt: opts.now(), text }), 'utf8');
55
+ }
56
+ catch {
57
+ // a cache that cannot be written only costs a fetch next time
58
+ }
59
+ failures.delete(failKey);
60
+ return text;
61
+ }
62
+ catch (err) {
63
+ failures.set(failKey, { at: opts.now(), message: err.message });
64
+ if (cached)
65
+ return cached.text;
66
+ throw err;
67
+ }
68
+ }
69
+ function fallbackName(url) {
70
+ const last = new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'remote-skill';
71
+ return last.replace(/\.md$/i, '') || 'remote-skill';
72
+ }
73
+ function asSkill(url, text) {
74
+ const head = text.trimStart();
75
+ if (head.startsWith('<') || !head.startsWith('---'))
76
+ return undefined;
77
+ const skill = parseSkill(text, { kind: 'remote', path: url }, fallbackName(url));
78
+ return skill.description.trim() && skill.body ? skill : undefined;
79
+ }
80
+ export async function loadRemoteSkills(url, options = {}) {
81
+ const warnings = [];
82
+ if (!isHttpUrl(url))
83
+ return { skills: [], warnings: [`${url}: only http and https skill URLs are read; skipped`] };
84
+ const opts = {
85
+ fetch: options.fetch ?? fetch,
86
+ cacheDir: options.cacheDir ?? path.join(homedir(), '.forge', 'skill-cache'),
87
+ ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
88
+ now: options.now ?? Date.now,
89
+ };
90
+ const loadOne = async (target) => {
91
+ try {
92
+ const skill = asSkill(target, await fetchText(target, opts));
93
+ if (!skill)
94
+ warnings.push(`${target}: not a skill (needs front matter with a description); skipped`);
95
+ return skill;
96
+ }
97
+ catch (err) {
98
+ warnings.push(`${target}: ${err.message}; skipped`);
99
+ return undefined;
100
+ }
101
+ };
102
+ let text;
103
+ try {
104
+ text = await fetchText(url, opts);
105
+ }
106
+ catch (err) {
107
+ return { skills: [], warnings: [`${url}: ${err.message}; skipped`] };
108
+ }
109
+ const trimmed = text.trimStart();
110
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
111
+ let index;
112
+ try {
113
+ index = JSON.parse(text);
114
+ }
115
+ catch {
116
+ return { skills: [], warnings: [`${url}: not a skill or a skill index; skipped`] };
117
+ }
118
+ const list = Array.isArray(index) ? index : index?.skills;
119
+ if (!Array.isArray(list))
120
+ return { skills: [], warnings: [`${url}: not a skill or a skill index; skipped`] };
121
+ const targets = list
122
+ .map((e) => (typeof e === 'string' ? e : e?.url))
123
+ .filter((u) => typeof u === 'string' && u.length > 0)
124
+ .slice(0, MAX_INDEX_ENTRIES)
125
+ .map((u) => new URL(u, url).href);
126
+ const skills = (await Promise.all(targets.map(loadOne))).filter((s) => s !== undefined);
127
+ return { skills, warnings };
128
+ }
129
+ const skill = asSkill(url, text);
130
+ return skill ? { skills: [skill], warnings } : { skills: [], warnings: [`${url}: not a skill (needs front matter with a description); skipped`] };
131
+ }
@@ -38,6 +38,16 @@ export type SkillSource = {
38
38
  } | {
39
39
  kind: 'global';
40
40
  path: string;
41
+ }
42
+ /** Ships inside Forge; there is no file, `path` is a label. */
43
+ | {
44
+ kind: 'builtin';
45
+ path: string;
46
+ }
47
+ /** Fetched from a URL listed in `skillPaths`; `path` is the URL. */
48
+ | {
49
+ kind: 'remote';
50
+ path: string;
41
51
  };
42
52
  /** A parsed skill, ready for activation. */
43
53
  export interface Skill {
@@ -12,15 +12,24 @@
12
12
  import type { SpecSections } from './types.js';
13
13
  /** Read-only tools exposed to the model during spec-mode generation. */
14
14
  export declare const SPEC_READONLY_TOOLS: readonly string[];
15
+ export declare function splitSpecSections(raw: string): SpecSections;
16
+ /**
17
+ * The Spec stage's two documents. Returns **only** those two keys: a model that
18
+ * volunteers a `## tasks.md` section despite being told not to must not be able
19
+ * to smuggle it into a draft, because the person has not reached the
20
+ * implementation-plan gate yet.
21
+ */
22
+ export declare function splitRequirementsDesign(raw: string): {
23
+ requirements: string;
24
+ design: string;
25
+ };
15
26
  /**
16
- * Split a spec-mode run's final text into its three sections. Each
17
- * section is introduced by a `## <file>.md` markdown heading (case-
18
- * insensitive, arbitrary surrounding whitespace); the body runs until
19
- * the next matching heading or end of text. Section order in `raw`
20
- * does not matter.
27
+ * The ImplPlan stage's single document.
21
28
  *
22
- * Throws when any of the three expected headings is missing: callers
23
- * should surface this as a generation failure rather than write partial
24
- * or malformed files.
29
+ * Throws when the reply holds no task at all: an empty body or a prose-only
30
+ * "Plan is complete." would otherwise be written by `writeDraftTasks` and then
31
+ * approved, because `approveDraft` only checks that the file exists — leaving a
32
+ * spec with nothing to run, which is the very outcome that guard exists to
33
+ * prevent. Failing generation here sends the journey back to "Revise".
25
34
  */
26
- export declare function splitSpecSections(raw: string): SpecSections;
35
+ export declare function splitTasksSection(raw: string): string;
@@ -9,6 +9,7 @@
9
9
  * order) into the three separate document bodies `SpecStore.approve`
10
10
  * writes to disk.
11
11
  */
12
+ import { convertToCheckboxes, parseTasks } from './tasks-parser.js';
12
13
  /** Read-only tools exposed to the model during spec-mode generation. */
13
14
  export const SPEC_READONLY_TOOLS = [
14
15
  'read_file',
@@ -23,37 +24,87 @@ const SECTION_MATCHES = [
23
24
  { key: 'design', header: 'design.md' },
24
25
  { key: 'tasks', header: 'tasks.md' },
25
26
  ];
27
+ /** Where a section body ends: the next `## ` heading. */
28
+ const STRICT_STOP = /^##\s+\S/m;
29
+ /** Stage replies: a level-1 or level-2 heading, or any named section heading at level 1 to 3. */
30
+ const LENIENT_STOP = /^(?:#{1,2}[ \t]+\S|#{1,3}[ \t]*`?(?:requirements|design|tasks)\.md`?[ \t]*$)/im;
31
+ /** A line that is nothing but a code fence. */
32
+ const FENCE_ONLY_LINE = /\n[ \t]*(?:```|~~~)[ \t]*$/;
33
+ const FENCE_LINE_ANYWHERE = /^[ \t]*(?:```|~~~)/gm;
26
34
  /**
27
- * Split a spec-mode run's final text into its three sections. Each
28
- * section is introduced by a `## <file>.md` markdown heading (case-
29
- * insensitive, arbitrary surrounding whitespace); the body runs until
30
- * the next matching heading or end of text. Section order in `raw`
31
- * does not matter.
35
+ * Drop the closing fence a model leaves behind when it wrapped the whole reply
36
+ * in a code block: the opening fence sits before the first heading, so the last
37
+ * section ends with an unmatched closing one.
32
38
  *
33
- * Throws when any of the three expected headings is missing: callers
34
- * should surface this as a generation failure rather than write partial
35
- * or malformed files.
39
+ * Only when the count is odd. A `tasks.md` that legitimately *ends* with a
40
+ * fenced code block has a balanced pair, and stripping that would eat the real
41
+ * document — which is what the existing one-shot test caught on the first run.
36
42
  */
37
- export function splitSpecSections(raw) {
43
+ function dropUnmatchedTrailingFence(body) {
44
+ const fences = body.match(FENCE_LINE_ANYWHERE)?.length ?? 0;
45
+ if (fences % 2 === 0)
46
+ return body;
47
+ // `$` without /m anchors at the very end of the string, not before a trailing
48
+ // newline, so the trailing whitespace has to go before the fence can match.
49
+ return body.trimEnd().replace(FENCE_ONLY_LINE, '');
50
+ }
51
+ /**
52
+ * Find every section the text contains, validating nothing. Callers decide
53
+ * which they require, so the one-shot path can keep demanding all three while
54
+ * the journey's Spec stage asks for two and its ImplPlan stage for one.
55
+ *
56
+ * A section's body runs to the next `## ` heading or the end of the text, so an
57
+ * unrelated heading the model wrote between two sections ends only its own
58
+ * section. Text before the first heading is never part of a body, which is what
59
+ * makes a preamble harmless.
60
+ *
61
+ * `lenient` is for the journey's stage replies and is opt-in; the one-shot
62
+ * `splitSpecSections` passes false and reads exactly as it always has. Lenient
63
+ * does two things:
64
+ *
65
+ * - It strips the closing fence a wrapped reply leaves behind. This once ran for
66
+ * both paths, which quietly changed what `/spec` persists (a wrapped reply
67
+ * lost its closing fence, and a tilde block holding a bare backtick line lost
68
+ * its own) and contradicted the plan's "nothing existing changes behaviour".
69
+ * - It accepts a section heading at level 1 to 3, optionally in backticks. A live
70
+ * MiniMax-M3 run answered with `# tasks.md` and the strict parser threw "got:
71
+ * none" although the model had done the work. Once a section may be headed
72
+ * `###`, a body cannot end at "any `##` line" or the first section would
73
+ * swallow the second, so it ends at a level-1/2 heading *or* at any named
74
+ * section heading; a level-3 subsection inside a document stays inside it.
75
+ *
76
+ * The one-shot flaws are real and recorded in RB-31; they are not fixed here.
77
+ */
78
+ function splitSections(raw, lenient) {
38
79
  const found = {};
39
80
  const foundHeaders = [];
40
81
  for (const { key, header } of SECTION_MATCHES) {
41
- const re = new RegExp(`^##\\s*${escapeRegExp(header)}\\s*$`, 'im');
82
+ const re = lenient
83
+ ? new RegExp(`^#{1,3}[ \\t]*\`?${escapeRegExp(header)}\`?[ \\t]*$`, 'im')
84
+ : new RegExp(`^##\\s*${escapeRegExp(header)}\\s*$`, 'im');
42
85
  const match = re.exec(raw);
43
86
  if (!match)
44
87
  continue;
45
88
  const start = match.index + match[0].length;
46
89
  const rest = raw.slice(start);
47
90
  // Body runs until the next `## ...` heading line, or end of text.
48
- const nextHeadingRe = /^##\s+\S/m;
49
- const nextMatch = nextHeadingRe.exec(rest);
50
- const body = nextMatch ? rest.slice(0, nextMatch.index) : rest;
91
+ const nextMatch = (lenient ? LENIENT_STOP : STRICT_STOP).exec(rest);
92
+ const slice = nextMatch ? rest.slice(0, nextMatch.index) : rest;
93
+ const body = lenient ? dropUnmatchedTrailingFence(slice) : slice;
51
94
  found[key] = key === 'tasks' ? splitInlineSubtasks(body.trim()) : body.trim();
52
95
  foundHeaders.push(header);
53
96
  }
97
+ return { found, foundHeaders };
98
+ }
99
+ function describeFound(foundHeaders) {
100
+ return foundHeaders.length > 0 ? foundHeaders.join(', ') : 'none';
101
+ }
102
+ export function splitSpecSections(raw) {
103
+ const { found, foundHeaders } = splitSections(raw, false);
104
+ // Strict on purpose: see `splitSections`. The one-shot path reads exactly as it always has.
54
105
  const missing = SECTION_MATCHES.filter((s) => found[s.key] === undefined);
55
106
  if (missing.length > 0) {
56
- throw new Error(`Spec generation did not include all three sections (requirements.md/design.md/tasks.md): got: ${foundHeaders.length > 0 ? foundHeaders.join(', ') : 'none'}`);
107
+ throw new Error(`Spec generation did not include all three sections (requirements.md/design.md/tasks.md): got: ${describeFound(foundHeaders)}`);
57
108
  }
58
109
  return {
59
110
  requirements: found.requirements,
@@ -61,6 +112,115 @@ export function splitSpecSections(raw) {
61
112
  tasks: found.tasks,
62
113
  };
63
114
  }
115
+ /**
116
+ * The Spec stage's two documents. Returns **only** those two keys: a model that
117
+ * volunteers a `## tasks.md` section despite being told not to must not be able
118
+ * to smuggle it into a draft, because the person has not reached the
119
+ * implementation-plan gate yet.
120
+ */
121
+ export function splitRequirementsDesign(raw) {
122
+ const { found, foundHeaders } = splitSections(raw, true);
123
+ const missing = ['requirements', 'design'].filter((k) => found[k] === undefined);
124
+ if (missing.length > 0) {
125
+ throw new Error(`Spec generation did not include ${missing.map((k) => `${k}.md`).join(' and ')}: got: ${describeFound(foundHeaders)}`);
126
+ }
127
+ return { requirements: found.requirements, design: found.design };
128
+ }
129
+ const CHECKBOX_LINE = /^\s*[-*+]\s+\[[ xX]\]\s+\S/;
130
+ const FENCE_START = /^\s*(?:```|~~~)/;
131
+ /** `_Requirements: 2_`, `*Requirements: 2*`, `**Requirements:** 1.1`: the footer under a task, however the model dressed it. */
132
+ const REQUIREMENTS_FOOTER = /^\s*[_*]{1,2}Requirements\b.*$/i;
133
+ /** A line that belongs to the task above it: indented, or its requirements footer. */
134
+ function continuesTask(line) {
135
+ return /^\s+\S/.test(line) || REQUIREMENTS_FOOTER.test(line);
136
+ }
137
+ /**
138
+ * Cut a closing summary off the end of a task list.
139
+ *
140
+ * Nothing ends the *last* section of a reply, so a model that finishes with
141
+ * "Notes on two judgment calls… Plan is complete, next step would be executing
142
+ * task 1" has that text written into the persisted `tasks.md`. A live
143
+ * MiniMax-M3 run did exactly that. The checklist is the anchor: the document
144
+ * ends where the last checkbox's own block ends.
145
+ *
146
+ * That block is the task's indented lines and its requirements footer, and it
147
+ * may contain blank lines, so a blank run is looked *through* and kept when what
148
+ * follows still belongs to the task. A fenced block under the last task is part
149
+ * of it and is kept whole; an *unclosed* fence is trailer, because keeping it
150
+ * would persist a document that ends inside a code block. The anchor itself is
151
+ * the last checkbox `parseTasks` would see, so one inside a fenced example does
152
+ * not count.
153
+ *
154
+ * With no checkbox at all (a prose or numbered list) there is nothing to anchor
155
+ * on, so the text is returned as it is and approval converts it to checkboxes
156
+ * the way the one-shot path always has.
157
+ */
158
+ function dropClosingSummary(tasks) {
159
+ const lines = tasks.split('\n');
160
+ let last = -1;
161
+ let inFence = false;
162
+ lines.forEach((line, i) => {
163
+ if (FENCE_START.test(line)) {
164
+ inFence = !inFence;
165
+ return;
166
+ }
167
+ if (!inFence && CHECKBOX_LINE.test(line))
168
+ last = i;
169
+ });
170
+ if (last === -1)
171
+ return tasks;
172
+ let end = last;
173
+ let i = last + 1;
174
+ while (i < lines.length) {
175
+ const line = lines[i];
176
+ if (line.trim() === '') {
177
+ let next = i + 1;
178
+ while (next < lines.length && lines[next].trim() === '')
179
+ next += 1;
180
+ const rest = lines[next];
181
+ if (rest !== undefined && (continuesTask(rest) || FENCE_START.test(rest))) {
182
+ i = next;
183
+ continue;
184
+ }
185
+ break;
186
+ }
187
+ if (FENCE_START.test(line)) {
188
+ let close = i + 1;
189
+ while (close < lines.length && !FENCE_START.test(lines[close]))
190
+ close += 1;
191
+ if (close >= lines.length)
192
+ break;
193
+ end = close;
194
+ i = close + 1;
195
+ continue;
196
+ }
197
+ if (!continuesTask(line))
198
+ break;
199
+ end = i;
200
+ i += 1;
201
+ }
202
+ return lines.slice(0, end + 1).join('\n').trimEnd();
203
+ }
204
+ /**
205
+ * The ImplPlan stage's single document.
206
+ *
207
+ * Throws when the reply holds no task at all: an empty body or a prose-only
208
+ * "Plan is complete." would otherwise be written by `writeDraftTasks` and then
209
+ * approved, because `approveDraft` only checks that the file exists — leaving a
210
+ * spec with nothing to run, which is the very outcome that guard exists to
211
+ * prevent. Failing generation here sends the journey back to "Revise".
212
+ */
213
+ export function splitTasksSection(raw) {
214
+ const { found, foundHeaders } = splitSections(raw, true);
215
+ if (found.tasks === undefined) {
216
+ throw new Error(`Spec generation did not include tasks.md: got: ${describeFound(foundHeaders)}`);
217
+ }
218
+ const tasks = dropClosingSummary(found.tasks);
219
+ if (parseTasks(convertToCheckboxes(tasks)).length === 0) {
220
+ throw new Error(`Spec generation produced a tasks.md with no tasks in it: got: ${describeFound(foundHeaders)}`);
221
+ }
222
+ return tasks;
223
+ }
64
224
  // A task line with a numbered subtask glued on after it: "- [ ] 1. Title - [ ] 1.1 Sub".
65
225
  const INLINE_SUBTASK_RE = /^(\s*)([-*+]\s+\[[ xX]\]\s+.+?)\s+[-*+]\s+(\[[ xX]\]\s+\d.*)$/;
66
226
  /**
@@ -6,8 +6,8 @@
6
6
  * `./generate.js`, added in a later task.
7
7
  */
8
8
  export { SpecStore, isSafeSpecId, runKey, slugifyTaskForSpecId } from './store.js';
9
- export type { SpecDraft, SpecRunRecord, SpecRuns, SpecSections, SpecSummary } from './types.js';
10
- export { SPEC_READONLY_TOOLS, splitSpecSections } from './generate.js';
9
+ export type { SpecDraft, SpecDraftSections, SpecRunRecord, SpecRuns, SpecSections, SpecSummary } from './types.js';
10
+ export { SPEC_READONLY_TOOLS, splitRequirementsDesign, splitSpecSections, splitTasksSection } from './generate.js';
11
11
  export { convertToCheckboxes, parseTasks, setTaskDone } from './tasks-parser.js';
12
12
  export type { SpecTask } from './tasks-parser.js';
13
13
  export { changedSince, changeSize, keepDoneTasks, referencedPaths, specDrift, syncSpecTask } from './sync.js';
@@ -6,6 +6,6 @@
6
6
  * `./generate.js`, added in a later task.
7
7
  */
8
8
  export { SpecStore, isSafeSpecId, runKey, slugifyTaskForSpecId } from './store.js';
9
- export { SPEC_READONLY_TOOLS, splitSpecSections } from './generate.js';
9
+ export { SPEC_READONLY_TOOLS, splitRequirementsDesign, splitSpecSections, splitTasksSection } from './generate.js';
10
10
  export { convertToCheckboxes, parseTasks, setTaskDone } from './tasks-parser.js';
11
11
  export { changedSince, changeSize, keepDoneTasks, referencedPaths, specDrift, syncSpecTask } from './sync.js';
@@ -7,7 +7,7 @@
7
7
  * generation happens entirely in memory (in the CLI/REPL layer), so a
8
8
  * cancelled review leaves no trace here.
9
9
  */
10
- import type { SpecDraft, SpecRunRecord, SpecRuns, SpecSections, SpecSummary } from './types.js';
10
+ import type { SpecDraft, SpecDraftSections, SpecRunRecord, SpecRuns, SpecSections, SpecSummary } from './types.js';
11
11
  /** Spec ids become path segments; the leading-alphanumeric rule also rejects `.drafts`, `.` and `..`. */
12
12
  export declare function isSafeSpecId(id: string): boolean;
13
13
  /** Key for a task's run history: a reordered or renamed task stops matching old runs. */
@@ -33,7 +33,19 @@ export declare class SpecStore {
33
33
  /** Delete an approved spec's directory. Returns false when it didn't exist. */
34
34
  delete(id: string): Promise<boolean>;
35
35
  private draftDir;
36
- createDraft(id: string, task: string, sections: SpecSections): Promise<SpecDraft>;
36
+ /**
37
+ * A draft may be created before its tasks exist: the journey's Spec stage
38
+ * writes requirements and design, and `tasks.md` arrives from the ImplPlan
39
+ * stage behind its own gate (`writeDraftTasks`). The one-shot `/spec` path
40
+ * passes all three and is unaffected.
41
+ */
42
+ createDraft(id: string, task: string, sections: SpecDraftSections): Promise<SpecDraft>;
43
+ /**
44
+ * Write (or replace) a draft's `tasks.md`, which is the ImplPlan stage's
45
+ * artefact. `writeFile` truncates, so running the stage again after the person
46
+ * chose "Revise them" replaces the list rather than appending to it.
47
+ */
48
+ writeDraftTasks(id: string, tasks: string): Promise<void>;
37
49
  getDraft(id: string): Promise<SpecDraft | null>;
38
50
  listDrafts(): Promise<SpecDraft[]>;
39
51
  /** Approve what is on disk now (the user may have edited it), converting tasks.md to checkboxes if needed. */