@ikarmic/forge-core 5.1.0 → 5.2.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 (87) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.js +39 -5
  3. package/dist/agent/goal.d.ts +5 -8
  4. package/dist/agent/prompts.d.ts +25 -1
  5. package/dist/agent/prompts.js +61 -17
  6. package/dist/agent/race-abort.d.ts +8 -0
  7. package/dist/agent/race-abort.js +24 -0
  8. package/dist/agent/stack-facts.d.ts +23 -0
  9. package/dist/agent/stack-facts.js +150 -0
  10. package/dist/code-index/parser.js +48 -3
  11. package/dist/github/github-tools.d.ts +2 -18
  12. package/dist/index.d.ts +7 -3
  13. package/dist/index.js +8 -3
  14. package/dist/journey/answers-block.d.ts +17 -0
  15. package/dist/journey/answers-block.js +28 -0
  16. package/dist/journey/index.d.ts +9 -0
  17. package/dist/journey/index.js +8 -0
  18. package/dist/journey/journey.d.ts +13 -0
  19. package/dist/journey/journey.js +40 -0
  20. package/dist/journey/offer.d.ts +25 -0
  21. package/dist/journey/offer.js +49 -0
  22. package/dist/journey/questions.d.ts +23 -0
  23. package/dist/journey/questions.js +49 -0
  24. package/dist/journey/stages.d.ts +13 -0
  25. package/dist/journey/stages.js +107 -0
  26. package/dist/journey/survey.d.ts +10 -0
  27. package/dist/journey/survey.js +203 -0
  28. package/dist/journey/trigger.d.ts +19 -0
  29. package/dist/journey/trigger.js +79 -0
  30. package/dist/journey/types.d.ts +72 -0
  31. package/dist/journey/types.js +1 -0
  32. package/dist/mcp/json-schema-to-zod.js +12 -1
  33. package/dist/orchestrator/index.d.ts +1 -1
  34. package/dist/orchestrator/index.js +1 -1
  35. package/dist/orchestrator/orchestrator.d.ts +45 -6
  36. package/dist/orchestrator/orchestrator.js +62 -12
  37. package/dist/orchestrator/router.d.ts +1 -1
  38. package/dist/orchestrator/router.js +4 -1
  39. package/dist/orchestrator/specialists.d.ts +6 -1
  40. package/dist/orchestrator/specialists.js +165 -68
  41. package/dist/orchestrator/types.d.ts +1 -1
  42. package/dist/policy/policy.d.ts +6 -59
  43. package/dist/pricing.d.ts +2 -0
  44. package/dist/pricing.js +35 -16
  45. package/dist/skills/builtin.js +1 -1
  46. package/dist/specs/generate.d.ts +18 -9
  47. package/dist/specs/generate.js +174 -14
  48. package/dist/specs/index.d.ts +2 -2
  49. package/dist/specs/index.js +1 -1
  50. package/dist/specs/store.d.ts +14 -2
  51. package/dist/specs/store.js +26 -1
  52. package/dist/specs/types.d.ts +11 -0
  53. package/dist/structured/schema.js +9 -12
  54. package/dist/tools/apply-patch.d.ts +1 -5
  55. package/dist/tools/ask-user.d.ts +2 -10
  56. package/dist/tools/ask-user.js +39 -3
  57. package/dist/tools/browser-tools.d.ts +15 -31
  58. package/dist/tools/codebase-search.d.ts +1 -7
  59. package/dist/tools/dependency-audit.d.ts +30 -0
  60. package/dist/tools/dependency-audit.js +197 -0
  61. package/dist/tools/edit-file.d.ts +1 -15
  62. package/dist/tools/fetch-url.d.ts +1 -18
  63. package/dist/tools/find-references.d.ts +1 -5
  64. package/dist/tools/find-symbol.d.ts +10 -8
  65. package/dist/tools/git-diff.d.ts +13 -0
  66. package/dist/tools/git-diff.js +85 -0
  67. package/dist/tools/index.d.ts +3 -1
  68. package/dist/tools/index.js +5 -1
  69. package/dist/tools/list-files.d.ts +1 -19
  70. package/dist/tools/list-symbols.d.ts +1 -5
  71. package/dist/tools/memory.d.ts +23 -43
  72. package/dist/tools/notebook.d.ts +11 -17
  73. package/dist/tools/read-file.d.ts +1 -11
  74. package/dist/tools/read-file.js +5 -0
  75. package/dist/tools/read-image.d.ts +1 -12
  76. package/dist/tools/read-only.d.ts +1 -1
  77. package/dist/tools/read-only.js +2 -0
  78. package/dist/tools/run-command.d.ts +1 -17
  79. package/dist/tools/search-files.d.ts +1 -17
  80. package/dist/tools/sub-agent-wait.d.ts +2 -8
  81. package/dist/tools/sub-agent.d.ts +2 -70
  82. package/dist/tools/sub-agent.js +3 -0
  83. package/dist/tools/todo.d.ts +8 -20
  84. package/dist/tools/web-search.d.ts +1 -7
  85. package/dist/tools/write-file.d.ts +1 -7
  86. package/dist/types.d.ts +27 -0
  87. package/package.json +3 -3
@@ -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. */
@@ -134,6 +134,12 @@ export class SpecStore {
134
134
  draftDir(id) {
135
135
  return path.join(this.root, DRAFTS_DIR, id);
136
136
  }
137
+ /**
138
+ * A draft may be created before its tasks exist: the journey's Spec stage
139
+ * writes requirements and design, and `tasks.md` arrives from the ImplPlan
140
+ * stage behind its own gate (`writeDraftTasks`). The one-shot `/spec` path
141
+ * passes all three and is unaffected.
142
+ */
137
143
  async createDraft(id, task, sections) {
138
144
  assertSafeSpecId(id);
139
145
  const dir = this.draftDir(id);
@@ -142,11 +148,24 @@ export class SpecStore {
142
148
  await mkdir(dir, { recursive: true });
143
149
  await writeFile(path.join(dir, 'requirements.md'), sections.requirements, 'utf8');
144
150
  await writeFile(path.join(dir, 'design.md'), sections.design, 'utf8');
145
- await writeFile(path.join(dir, 'tasks.md'), sections.tasks, 'utf8');
151
+ if (sections.tasks !== undefined)
152
+ await writeFile(path.join(dir, 'tasks.md'), sections.tasks, 'utf8');
146
153
  const createdAt = new Date().toISOString();
147
154
  await writeFile(path.join(dir, DRAFT_FILENAME), JSON.stringify({ id, task, createdAt }, null, 2), 'utf8');
148
155
  return { id, task, createdAt, dir };
149
156
  }
157
+ /**
158
+ * Write (or replace) a draft's `tasks.md`, which is the ImplPlan stage's
159
+ * artefact. `writeFile` truncates, so running the stage again after the person
160
+ * chose "Revise them" replaces the list rather than appending to it.
161
+ */
162
+ async writeDraftTasks(id, tasks) {
163
+ assertSafeSpecId(id);
164
+ const draft = await this.getDraft(id);
165
+ if (!draft)
166
+ throw new Error(`no draft ${id}`);
167
+ await writeFile(path.join(draft.dir, 'tasks.md'), tasks, 'utf8');
168
+ }
150
169
  async getDraft(id) {
151
170
  if (!isSafeSpecId(id))
152
171
  return null;
@@ -187,6 +206,12 @@ export class SpecStore {
187
206
  // Finish the spec inside the draft folder, then move it into place with one rename,
188
207
  // so a spec directory never exists half-written.
189
208
  const tasksPath = path.join(draft.dir, 'tasks.md');
209
+ // A journey draft has no tasks until the ImplPlan stage has run. Approving
210
+ // then would produce a spec the Specs tree shows with nothing to run, so say
211
+ // what is missing instead.
212
+ if (!existsSync(tasksPath)) {
213
+ throw new Error(`draft ${id} has no tasks.md: the implementation plan stage has not run yet`);
214
+ }
190
215
  await writeFile(tasksPath, convertToCheckboxes(await readFile(tasksPath, 'utf8')), 'utf8');
191
216
  const summary = { id, task: draft.task, approvedAt: new Date().toISOString(), dir: specDir };
192
217
  await writeFile(path.join(draft.dir, MANIFEST_FILENAME), JSON.stringify(summary, null, 2), 'utf8');
@@ -4,6 +4,17 @@ export interface SpecSections {
4
4
  design: string;
5
5
  tasks: string;
6
6
  }
7
+ /**
8
+ * A draft mid-journey. The Spec stage writes requirements and design; `tasks`
9
+ * arrives later, from the ImplPlan stage, behind its own gate. `SpecSections`
10
+ * deliberately stays all-required: the one-shot `/spec` path produces all three
11
+ * at once and nothing about it changes.
12
+ */
13
+ export interface SpecDraftSections {
14
+ requirements: string;
15
+ design: string;
16
+ tasks?: string;
17
+ }
7
18
  /** An unapproved spec in `.forge/specs/.drafts/<id>/`. */
8
19
  export interface SpecDraft {
9
20
  id: string;
@@ -258,36 +258,33 @@ export function formatZodError(err) {
258
258
  return err.issues
259
259
  .map((issue) => {
260
260
  const pathStr = issue.path.length > 0 ? issue.path.join('.') : '(root)';
261
- const expected = issueCode(issue.code);
261
+ const expected = issueCode(issue);
262
262
  const received = describeReceived(issue);
263
263
  return `${pathStr}: ${issue.message}${expected ? ` (expected ${expected}${received ? `, got ${received}` : ''})` : ''}`;
264
264
  })
265
265
  .join('\n');
266
266
  }
267
- function issueCode(code) {
268
- switch (code) {
267
+ function issueCode(issue) {
268
+ switch (issue.code) {
269
269
  case 'invalid_type':
270
270
  return 'correct type';
271
- case 'invalid_string':
271
+ case 'invalid_format':
272
272
  return 'string matching format';
273
273
  case 'too_small':
274
274
  return 'value >= min';
275
275
  case 'too_big':
276
276
  return 'value <= max';
277
- case 'invalid_enum_value':
278
- return 'one of the allowed values';
277
+ case 'invalid_value':
278
+ return issue.values.length === 1 ? 'the literal value' : 'one of the allowed values';
279
279
  case 'unrecognized_keys':
280
280
  return 'no extra keys';
281
281
  case 'invalid_union':
282
282
  return 'one of the union variants';
283
- case 'invalid_literal':
284
- return 'the literal value';
285
283
  default:
286
284
  return '';
287
285
  }
288
286
  }
289
- function describeReceived(issue) {
290
- if (issue.code !== 'invalid_type')
291
- return '';
292
- return JSON.stringify(issue.received);
287
+ // Zod 4 dropped `received` from the issue; its message already says "received <type>".
288
+ function describeReceived(_issue) {
289
+ return '';
293
290
  }
@@ -2,11 +2,7 @@ import { z } from 'zod';
2
2
  import type { ToolDefinition } from '../types.js';
3
3
  declare const params: z.ZodObject<{
4
4
  patch: z.ZodString;
5
- }, "strip", z.ZodTypeAny, {
6
- patch: string;
7
- }, {
8
- patch: string;
9
- }>;
5
+ }, z.core.$strip>;
10
6
  export type PatchOp = {
11
7
  kind: 'add';
12
8
  path: string;
@@ -7,17 +7,9 @@ import type { ToolDefinition } from '../types.js';
7
7
  */
8
8
  declare const params: z.ZodObject<{
9
9
  question: z.ZodString;
10
- options: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
10
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
11
  allowCustom: z.ZodOptional<z.ZodBoolean>;
12
- }, "strip", z.ZodTypeAny, {
13
- question: string;
14
- options?: string[] | undefined;
15
- allowCustom?: boolean | undefined;
16
- }, {
17
- question: string;
18
- options?: string[] | undefined;
19
- allowCustom?: boolean | undefined;
20
- }>;
12
+ }, z.core.$strip>;
21
13
  export interface AskUserResult {
22
14
  answer: string;
23
15
  unavailable?: boolean;
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { raceAbort } from '../agent/race-abort.js';
2
3
  /**
3
4
  * Ask the person a question with optional choices. The agent only offers this tool when a surface can answer
4
5
  * (AgentConfig.askUser) and the run is not autonomous; if it is called anyway, or the run is stopped while the
@@ -14,6 +15,18 @@ const UNAVAILABLE = {
14
15
  unavailable: true,
15
16
  note: 'No user is available. Choose the safest reasonable option and say what you assumed.',
16
17
  };
18
+ /**
19
+ * A guided journey asks at most four questions. The budget is enforced here
20
+ * rather than in prompt text for the same reason stage gates live in the tool
21
+ * sandbox: a limit the model is merely told about is a limit it will exceed,
22
+ * and one bad extra question is what makes an interview feel like an
23
+ * interrogation (spec anti-goal 3).
24
+ */
25
+ const BUDGET_SPENT = {
26
+ answer: '',
27
+ unavailable: true,
28
+ note: 'The question budget for this journey is spent: no more questions. Proceed with what you have and say what you assumed.',
29
+ };
17
30
  export const askUserTool = {
18
31
  name: 'ask_user',
19
32
  description: 'Ask the user a clarifying question when you cannot proceed safely without their answer. Give a few options when you can. Do not ask for permission to run tools; approvals are handled separately.',
@@ -25,8 +38,31 @@ export const askUserTool = {
25
38
  const signal = ctx.abortSignal;
26
39
  if (signal?.aborted)
27
40
  return UNAVAILABLE;
28
- const answered = ctx.askUser(args, signal).then((answer) => ({ answer }));
29
- const aborted = new Promise((resolve) => signal?.addEventListener('abort', () => resolve(UNAVAILABLE), { once: true }));
30
- return Promise.race([answered, aborted]);
41
+ // Counted only once the question is actually put to a surface, so a refused
42
+ // or impossible question never spends part of the budget.
43
+ if (ctx.askBudget) {
44
+ if (ctx.askBudget.used >= ctx.askBudget.max)
45
+ return BUDGET_SPENT;
46
+ ctx.askBudget.used += 1;
47
+ }
48
+ const budget = ctx.askBudget;
49
+ const refund = () => {
50
+ if (budget)
51
+ budget.used = Math.max(0, budget.used - 1);
52
+ };
53
+ const answered = ctx.askUser(args, signal).then((answer) => {
54
+ // A question the person dismissed resolves to an empty answer and must not
55
+ // cost part of the budget: four dismissals would otherwise end an interview
56
+ // without a single question being answered. Refunding keeps the budget a
57
+ // count of questions actually answered.
58
+ if (answer.trim() === '')
59
+ refund();
60
+ return { answer };
61
+ }, (err) => {
62
+ // A surface that failed never showed the question, so it costs nothing either.
63
+ refund();
64
+ throw err;
65
+ });
66
+ return raceAbort(answered, signal, UNAVAILABLE);
31
67
  },
32
68
  };
@@ -4,15 +4,15 @@ import type { ToolContext, ToolDefinition } from '../types.js';
4
4
  /** Throws unless `rawUrl` is an http(s) URL the browser may visit. */
5
5
  export declare function assertBrowsableUrl(rawUrl: string, policy: ToolContext['policy']): Promise<void>;
6
6
  declare const observeParams: z.ZodObject<{
7
- action: z.ZodEnum<["open", "screenshot", "text", "console", "close"]>;
7
+ action: z.ZodEnum<{
8
+ close: "close";
9
+ text: "text";
10
+ open: "open";
11
+ screenshot: "screenshot";
12
+ console: "console";
13
+ }>;
8
14
  url: z.ZodOptional<z.ZodString>;
9
- }, "strip", z.ZodTypeAny, {
10
- action: "close" | "text" | "open" | "screenshot" | "console";
11
- url?: string | undefined;
12
- }, {
13
- action: "close" | "text" | "open" | "screenshot" | "console";
14
- url?: string | undefined;
15
- }>;
15
+ }, z.core.$strip>;
16
16
  export type BrowserResult = {
17
17
  action: 'open';
18
18
  url: string;
@@ -48,30 +48,14 @@ export declare function createBrowserTools(session: BrowserSession): [
48
48
  note?: string;
49
49
  }>
50
50
  ];
51
- declare const actParams: z.ZodEffects<z.ZodObject<{
52
- action: z.ZodEnum<["click", "type", "evaluate"]>;
51
+ declare const actParams: z.ZodObject<{
52
+ action: z.ZodEnum<{
53
+ type: "type";
54
+ click: "click";
55
+ evaluate: "evaluate";
56
+ }>;
53
57
  selector: z.ZodOptional<z.ZodString>;
54
58
  text: z.ZodOptional<z.ZodString>;
55
59
  expression: z.ZodOptional<z.ZodString>;
56
- }, "strip", z.ZodTypeAny, {
57
- action: "type" | "click" | "evaluate";
58
- text?: string | undefined;
59
- expression?: string | undefined;
60
- selector?: string | undefined;
61
- }, {
62
- action: "type" | "click" | "evaluate";
63
- text?: string | undefined;
64
- expression?: string | undefined;
65
- selector?: string | undefined;
66
- }>, {
67
- action: "type" | "click" | "evaluate";
68
- text?: string | undefined;
69
- expression?: string | undefined;
70
- selector?: string | undefined;
71
- }, {
72
- action: "type" | "click" | "evaluate";
73
- text?: string | undefined;
74
- expression?: string | undefined;
75
- selector?: string | undefined;
76
- }>;
60
+ }, z.core.$strip>;
77
61
  export {};
@@ -5,13 +5,7 @@ import type { ToolDefinition } from '../types.js';
5
5
  declare const params: z.ZodObject<{
6
6
  query: z.ZodString;
7
7
  limit: z.ZodOptional<z.ZodNumber>;
8
- }, "strip", z.ZodTypeAny, {
9
- query: string;
10
- limit?: number | undefined;
11
- }, {
12
- query: string;
13
- limit?: number | undefined;
14
- }>;
8
+ }, z.core.$strip>;
15
9
  export interface CodebaseSearchResult {
16
10
  query: string;
17
11
  embedder: string;
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ export declare const AUDIT_TIMEOUT_MS = 60000;
4
+ export declare const MAX_AUDIT_CHARS = 60000;
5
+ export interface AuditFinding {
6
+ package: string;
7
+ installed?: string;
8
+ advisory: string;
9
+ severity: string;
10
+ fixedIn?: string;
11
+ }
12
+ export interface AuditorPlan {
13
+ ecosystem: 'npm' | 'pnpm' | 'yarn' | 'python' | 'cargo' | 'go';
14
+ program: string;
15
+ args: string[];
16
+ lockfile: string;
17
+ }
18
+ /** The auditors for the lockfiles at the workspace root, in a fixed order. */
19
+ export declare function pickAuditors(cwd: string): AuditorPlan[];
20
+ /** npm 7+ (`auditReportVersion: 2`): one finding per package with its own advisory; entries that only inherit one are skipped. */
21
+ export declare function parseNpmAudit(text: string): AuditFinding[];
22
+ /** pnpm (npm 6 format): `advisories` keyed by id. */
23
+ export declare function parsePnpmAudit(text: string): AuditFinding[];
24
+ /** pip-audit `-f json`: `{ dependencies: [...] }` (older versions print the list itself). pip-audit reports no severity. */
25
+ export declare function parsePipAudit(text: string): AuditFinding[];
26
+ /** cargo-audit `--json`: `vulnerabilities.list`. */
27
+ export declare function parseCargoAudit(text: string): AuditFinding[];
28
+ declare const params: z.ZodObject<{}, z.core.$strip>;
29
+ export declare const dependencyAuditTool: ToolDefinition<z.infer<typeof params>, string>;
30
+ export {};