@ikarmic/forge-core 5.1.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.
- package/README.md +1 -1
- package/dist/agent/agent.js +26 -3
- package/dist/agent/prompts.d.ts +25 -1
- package/dist/agent/prompts.js +61 -17
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -1
- package/dist/journey/answers-block.d.ts +17 -0
- package/dist/journey/answers-block.js +15 -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 +48 -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/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/tools/ask-user.js +29 -1
- package/dist/tools/read-file.js +5 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +3 -0
- package/dist/types.d.ts +25 -0
- package/package.json +1 -1
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;
|
package/dist/tools/ask-user.js
CHANGED
|
@@ -14,6 +14,18 @@ const UNAVAILABLE = {
|
|
|
14
14
|
unavailable: true,
|
|
15
15
|
note: 'No user is available. Choose the safest reasonable option and say what you assumed.',
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* A guided journey asks at most four questions. The budget is enforced here
|
|
19
|
+
* rather than in prompt text for the same reason stage gates live in the tool
|
|
20
|
+
* sandbox: a limit the model is merely told about is a limit it will exceed,
|
|
21
|
+
* and one bad extra question is what makes an interview feel like an
|
|
22
|
+
* interrogation (spec anti-goal 3).
|
|
23
|
+
*/
|
|
24
|
+
const BUDGET_SPENT = {
|
|
25
|
+
answer: '',
|
|
26
|
+
unavailable: true,
|
|
27
|
+
note: 'The question budget for this journey is spent: no more questions. Proceed with what you have and say what you assumed.',
|
|
28
|
+
};
|
|
17
29
|
export const askUserTool = {
|
|
18
30
|
name: 'ask_user',
|
|
19
31
|
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,7 +37,23 @@ export const askUserTool = {
|
|
|
25
37
|
const signal = ctx.abortSignal;
|
|
26
38
|
if (signal?.aborted)
|
|
27
39
|
return UNAVAILABLE;
|
|
28
|
-
|
|
40
|
+
// Counted only once the question is actually put to a surface, so a refused
|
|
41
|
+
// or impossible question never spends part of the budget.
|
|
42
|
+
if (ctx.askBudget) {
|
|
43
|
+
if (ctx.askBudget.used >= ctx.askBudget.max)
|
|
44
|
+
return BUDGET_SPENT;
|
|
45
|
+
ctx.askBudget.used += 1;
|
|
46
|
+
}
|
|
47
|
+
const budget = ctx.askBudget;
|
|
48
|
+
const answered = ctx.askUser(args, signal).then((answer) => {
|
|
49
|
+
// A question the person dismissed resolves to an empty answer and must not
|
|
50
|
+
// cost part of the budget: four dismissals would otherwise end an interview
|
|
51
|
+
// without a single question being answered. Refunding keeps the budget a
|
|
52
|
+
// count of questions actually answered.
|
|
53
|
+
if (budget && answer.trim() === '')
|
|
54
|
+
budget.used = Math.max(0, budget.used - 1);
|
|
55
|
+
return { answer };
|
|
56
|
+
});
|
|
29
57
|
const aborted = new Promise((resolve) => signal?.addEventListener('abort', () => resolve(UNAVAILABLE), { once: true }));
|
|
30
58
|
return Promise.race([answered, aborted]);
|
|
31
59
|
},
|
package/dist/tools/read-file.js
CHANGED
|
@@ -55,6 +55,11 @@ export const readFileTool = {
|
|
|
55
55
|
}
|
|
56
56
|
const text = decoded.text;
|
|
57
57
|
const lines = text.split(/\r?\n/);
|
|
58
|
+
// A file that ends with a newline splits into one extra, empty piece. It is not a line. Numbering it told the model a
|
|
59
|
+
// 37-line file had 38 lines and that the last was empty (a live spec caught it), and made a file of exactly max_lines
|
|
60
|
+
// lines look truncated. A genuinely blank last line ("a\n\n") still counts: only the piece after the final newline goes.
|
|
61
|
+
if (lines.length > 1 && lines[lines.length - 1] === '')
|
|
62
|
+
lines.pop();
|
|
58
63
|
const offset = args.offset ?? 1;
|
|
59
64
|
const maxLines = args.max_lines ?? 2000;
|
|
60
65
|
const slice = lines.slice(offset - 1, offset - 1 + maxLines);
|
|
@@ -145,7 +145,7 @@ export declare function childAgentConfig(setup: {
|
|
|
145
145
|
model: ModelConfig;
|
|
146
146
|
maxIterations: number;
|
|
147
147
|
systemPrompt?: string;
|
|
148
|
-
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox'>, cwd: string, auto: boolean): AgentConfig;
|
|
148
|
+
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox' | 'askBudget'>, cwd: string, auto: boolean): AgentConfig;
|
|
149
149
|
/** What a child runs without asking: what the parent does, else the read-only tools. Destructive tools still never run unasked. */
|
|
150
150
|
export declare function childAutoApprove(ctx: Pick<ToolContext, 'autoApproveTools'>): string[];
|
|
151
151
|
/** The configured sub-agent model, else the parent's, else a last-resort default. */
|
package/dist/tools/sub-agent.js
CHANGED
|
@@ -328,6 +328,9 @@ export function childAgentConfig(setup, ctx, cwd, auto) {
|
|
|
328
328
|
autoApproveTools: childAutoApprove(ctx),
|
|
329
329
|
...(ctx.permissionRules ? { permissionRules: ctx.permissionRules } : {}),
|
|
330
330
|
...(ctx.toolSandbox ? { toolSandbox: ctx.toolSandbox } : {}),
|
|
331
|
+
// The same budget object, not a copy: a child that started its own four
|
|
332
|
+
// would be a way around the limit the parent is under (RB-14).
|
|
333
|
+
...(ctx.askBudget ? { askBudget: ctx.askBudget } : {}),
|
|
331
334
|
// The child's toolset is the built-in set minus sub_agent, narrowed by the agent file when one is named.
|
|
332
335
|
disableBuiltins: true,
|
|
333
336
|
extraTools: setup.tools,
|
package/dist/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ModelMessage, LanguageModel } from 'ai';
|
|
|
2
2
|
import type { z } from 'zod';
|
|
3
3
|
import type { BackgroundAgents } from './agent/background.js';
|
|
4
4
|
import type { Attachment } from './attachments.js';
|
|
5
|
+
import type { Answer } from './journey/types.js';
|
|
5
6
|
import type { SkillRegistry } from './skills/types.js';
|
|
6
7
|
/**
|
|
7
8
|
* The summarizer takes a chunk of messages and returns a structured
|
|
@@ -114,6 +115,15 @@ export interface ToolContext {
|
|
|
114
115
|
maxOutputChars?: number;
|
|
115
116
|
/** Asks the person a question (`ask_user`); absent when no surface can answer. */
|
|
116
117
|
askUser?: (question: UserQuestion, signal?: AbortSignal) => Promise<string>;
|
|
118
|
+
/**
|
|
119
|
+
* How many questions this run may still ask (a guided journey's interview
|
|
120
|
+
* budget). Mutated in place as questions are asked, and handed to a child
|
|
121
|
+
* agent so a child cannot start a fresh four (RB-14). Absent means no limit.
|
|
122
|
+
*/
|
|
123
|
+
askBudget?: {
|
|
124
|
+
max: number;
|
|
125
|
+
used: number;
|
|
126
|
+
};
|
|
117
127
|
/** The run's todo list. `set` also tells the UI (`todo_updated`). Supplied by the Agent. */
|
|
118
128
|
todos?: {
|
|
119
129
|
get(): TodoItem[];
|
|
@@ -570,6 +580,13 @@ export interface AgentConfig {
|
|
|
570
580
|
* this project is checked and built. Added to the system prompt after the project instructions.
|
|
571
581
|
*/
|
|
572
582
|
projectCommands?: string;
|
|
583
|
+
/**
|
|
584
|
+
* Answers the person gave in a guided journey's interview, rendered into the
|
|
585
|
+
* system prompt as a `<journey-answers>` block. The interview runs before the
|
|
586
|
+
* first agent run, because the blocks are assembled only on a session's first
|
|
587
|
+
* turn (see `buildInitialMessages`), so answers that arrive later cannot enter.
|
|
588
|
+
*/
|
|
589
|
+
journeyAnswers?: Answer[];
|
|
573
590
|
/**
|
|
574
591
|
* Override the byte budget for memory injected into the system prompt.
|
|
575
592
|
* Default 8 KB. Larger budgets use more context window but surface more
|
|
@@ -734,6 +751,14 @@ export interface AgentConfig {
|
|
|
734
751
|
* Answers `ask_user`. When absent, or when `auto` is on, the model is not offered the tool.
|
|
735
752
|
*/
|
|
736
753
|
askUser?: (question: UserQuestion, signal?: AbortSignal) => Promise<string>;
|
|
754
|
+
/**
|
|
755
|
+
* How many `ask_user` questions this run may ask. Reaches tools as
|
|
756
|
+
* `ToolContext.askBudget` and is handed down to sub-agents. Absent means no limit.
|
|
757
|
+
*/
|
|
758
|
+
askBudget?: {
|
|
759
|
+
max: number;
|
|
760
|
+
used: number;
|
|
761
|
+
};
|
|
737
762
|
/** The model `sub_agent` children run on; the parent's model when unset. */
|
|
738
763
|
subAgentModel?: ModelConfig;
|
|
739
764
|
/** Where background sub-agents are kept between messages. Without it `sub_agent { background: true }` runs the child to completion. */
|
package/package.json
CHANGED