@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.
- package/README.md +1 -1
- package/dist/agent/agent.js +39 -5
- package/dist/agent/goal.d.ts +5 -8
- package/dist/agent/prompts.d.ts +25 -1
- package/dist/agent/prompts.js +61 -17
- package/dist/agent/race-abort.d.ts +8 -0
- package/dist/agent/race-abort.js +24 -0
- package/dist/agent/stack-facts.d.ts +23 -0
- package/dist/agent/stack-facts.js +150 -0
- package/dist/code-index/parser.js +48 -3
- package/dist/github/github-tools.d.ts +2 -18
- package/dist/index.d.ts +7 -3
- package/dist/index.js +8 -3
- package/dist/journey/answers-block.d.ts +17 -0
- package/dist/journey/answers-block.js +28 -0
- package/dist/journey/index.d.ts +9 -0
- package/dist/journey/index.js +8 -0
- package/dist/journey/journey.d.ts +13 -0
- package/dist/journey/journey.js +40 -0
- package/dist/journey/offer.d.ts +25 -0
- package/dist/journey/offer.js +49 -0
- package/dist/journey/questions.d.ts +23 -0
- package/dist/journey/questions.js +49 -0
- package/dist/journey/stages.d.ts +13 -0
- package/dist/journey/stages.js +107 -0
- package/dist/journey/survey.d.ts +10 -0
- package/dist/journey/survey.js +203 -0
- package/dist/journey/trigger.d.ts +19 -0
- package/dist/journey/trigger.js +79 -0
- package/dist/journey/types.d.ts +72 -0
- package/dist/journey/types.js +1 -0
- package/dist/mcp/json-schema-to-zod.js +12 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/orchestrator.d.ts +45 -6
- package/dist/orchestrator/orchestrator.js +62 -12
- package/dist/orchestrator/router.d.ts +1 -1
- package/dist/orchestrator/router.js +4 -1
- package/dist/orchestrator/specialists.d.ts +6 -1
- package/dist/orchestrator/specialists.js +165 -68
- package/dist/orchestrator/types.d.ts +1 -1
- package/dist/policy/policy.d.ts +6 -59
- package/dist/pricing.d.ts +2 -0
- package/dist/pricing.js +35 -16
- package/dist/skills/builtin.js +1 -1
- package/dist/specs/generate.d.ts +18 -9
- package/dist/specs/generate.js +174 -14
- package/dist/specs/index.d.ts +2 -2
- package/dist/specs/index.js +1 -1
- package/dist/specs/store.d.ts +14 -2
- package/dist/specs/store.js +26 -1
- package/dist/specs/types.d.ts +11 -0
- package/dist/structured/schema.js +9 -12
- package/dist/tools/apply-patch.d.ts +1 -5
- package/dist/tools/ask-user.d.ts +2 -10
- package/dist/tools/ask-user.js +39 -3
- package/dist/tools/browser-tools.d.ts +15 -31
- package/dist/tools/codebase-search.d.ts +1 -7
- package/dist/tools/dependency-audit.d.ts +30 -0
- package/dist/tools/dependency-audit.js +197 -0
- package/dist/tools/edit-file.d.ts +1 -15
- package/dist/tools/fetch-url.d.ts +1 -18
- package/dist/tools/find-references.d.ts +1 -5
- package/dist/tools/find-symbol.d.ts +10 -8
- package/dist/tools/git-diff.d.ts +13 -0
- package/dist/tools/git-diff.js +85 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.js +5 -1
- package/dist/tools/list-files.d.ts +1 -19
- package/dist/tools/list-symbols.d.ts +1 -5
- package/dist/tools/memory.d.ts +23 -43
- package/dist/tools/notebook.d.ts +11 -17
- package/dist/tools/read-file.d.ts +1 -11
- package/dist/tools/read-file.js +5 -0
- package/dist/tools/read-image.d.ts +1 -12
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +2 -0
- package/dist/tools/run-command.d.ts +1 -17
- package/dist/tools/search-files.d.ts +1 -17
- package/dist/tools/sub-agent-wait.d.ts +2 -8
- package/dist/tools/sub-agent.d.ts +2 -70
- package/dist/tools/sub-agent.js +3 -0
- package/dist/tools/todo.d.ts +8 -20
- package/dist/tools/web-search.d.ts +1 -7
- package/dist/tools/write-file.d.ts +1 -7
- package/dist/types.d.ts +27 -0
- package/package.json +3 -3
package/dist/specs/generate.js
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
|
49
|
-
const
|
|
50
|
-
const body =
|
|
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
|
|
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
|
/**
|
package/dist/specs/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/specs/index.js
CHANGED
|
@@ -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';
|
package/dist/specs/store.d.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/specs/store.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/dist/specs/types.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
268
|
-
switch (code) {
|
|
267
|
+
function issueCode(issue) {
|
|
268
|
+
switch (issue.code) {
|
|
269
269
|
case 'invalid_type':
|
|
270
270
|
return 'correct type';
|
|
271
|
-
case '
|
|
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 '
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
},
|
|
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;
|
package/dist/tools/ask-user.d.ts
CHANGED
|
@@ -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
|
|
10
|
+
options: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
11
11
|
allowCustom: z.ZodOptional<z.ZodBoolean>;
|
|
12
|
-
},
|
|
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;
|
package/dist/tools/ask-user.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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<
|
|
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
|
-
},
|
|
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.
|
|
52
|
-
action: z.ZodEnum<
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 {};
|