crewhaus 0.2.4 → 0.3.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/dist/ci-scaffold.d.ts +18 -0
- package/dist/ci-scaffold.js +74 -0
- package/dist/doctor-probe.d.ts +72 -0
- package/dist/doctor-probe.js +92 -0
- package/dist/dream-cli.d.ts +53 -0
- package/dist/dream-cli.js +329 -0
- package/dist/fleet.d.ts +50 -0
- package/dist/fleet.js +78 -2
- package/dist/index.js +716 -128
- package/dist/init-conversation.d.ts +87 -0
- package/dist/init-conversation.js +306 -0
- package/dist/init-interactive.d.ts +39 -39
- package/dist/init-interactive.js +66 -56
- package/dist/lint.js +17 -1
- package/dist/memory-cli.d.ts +92 -0
- package/dist/memory-cli.js +278 -0
- package/dist/run-failure.d.ts +34 -0
- package/dist/run-failure.js +38 -0
- package/dist/sessions-index.d.ts +7 -19
- package/dist/sessions-index.js +7 -43
- package/dist/thredz-probe.d.ts +36 -0
- package/dist/thredz-probe.js +93 -0
- package/dist/upgrade.d.ts +42 -0
- package/dist/upgrade.js +110 -6
- package/dist/wiki-cli.d.ts +40 -0
- package/dist/wiki-cli.js +133 -0
- package/package.json +92 -89
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
|
|
2
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
3
|
+
import { type Spec } from "@crewhaus/spec";
|
|
4
|
+
/** The saved-interview hint appended to every terminal-failure report and to
|
|
5
|
+
* the incomplete-interview error (design §2.9's exact promise). */
|
|
6
|
+
export declare const INIT_INTERVIEW_SAVED_NOTE = "Your interview is saved \u2014 `crewhaus init --interactive --resume` continues where it stopped.";
|
|
7
|
+
/** The synthetic kick-off turn a `--resume` boot runs so the FIRST assistant
|
|
8
|
+
* message is the resume summary — the user never re-types anything. */
|
|
9
|
+
export declare const RESUME_KICKOFF_MESSAGE: string;
|
|
10
|
+
/** The interview ended (exit/EOF/no emit) without a validated spec. Message
|
|
11
|
+
* carries the resume hint; `kind: "config"` keeps die()'s one-liner shape. */
|
|
12
|
+
export declare class InterviewIncompleteError extends CrewhausError {
|
|
13
|
+
readonly name = "InterviewIncompleteError";
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* v0.3.0 §2.9 — TTY gate for the conversational path, mirroring the CLI's
|
|
18
|
+
* destructive-verb convention (`memory clear`, `state restore`): interactive
|
|
19
|
+
* conversation needs an interactive terminal, and `--yes` explicitly asks
|
|
20
|
+
* for the non-conversational path. Pure; `index.ts` threads the real
|
|
21
|
+
* `process.stdin.isTTY`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function conversationalPathAllowed(opts: {
|
|
24
|
+
readonly stdinIsTTY: boolean;
|
|
25
|
+
readonly assumeYes: boolean;
|
|
26
|
+
}): {
|
|
27
|
+
readonly allowed: boolean;
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Extract the REQ → spec-field mapping lines from the interview transcript:
|
|
32
|
+
* the LAST assistant message containing `REQ-nnn … →` lines wins (the
|
|
33
|
+
* discipline mandates the mapping in the reply that precedes `emit_spec`).
|
|
34
|
+
* Best-effort and pure — the CLI prints whatever the model actually
|
|
35
|
+
* produced; correctness of the written spec never depends on it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractReqMappingLines(rawJsonl: string): string[];
|
|
38
|
+
export type ConversationalInterviewResult = {
|
|
39
|
+
readonly yaml: string;
|
|
40
|
+
readonly spec: Spec;
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
/** The REQ → spec-field mapping lines from the model's final reply
|
|
43
|
+
* (empty when the model produced none — best-effort surface). */
|
|
44
|
+
readonly mappingLines: readonly string[];
|
|
45
|
+
};
|
|
46
|
+
export type ConversationalInterviewOptions = {
|
|
47
|
+
/** The harness directory: the spec lands here, and the interview's own
|
|
48
|
+
* continuity state + session live under `<targetDir>/.crewhaus/`. */
|
|
49
|
+
readonly targetDir: string;
|
|
50
|
+
/** Continuity/session scope name — `init-<dirname>` by convention. */
|
|
51
|
+
readonly specName: string;
|
|
52
|
+
/** Model string for the interview (the router grammar). */
|
|
53
|
+
readonly model: string;
|
|
54
|
+
/** `--resume`: restart the most recent persisted interview session. */
|
|
55
|
+
readonly resume?: boolean;
|
|
56
|
+
/** Status-line sink; defaults to process.stdout. */
|
|
57
|
+
readonly log?: (line: string) => void;
|
|
58
|
+
/** Scripted ProviderAdapter — bypasses the model router. */
|
|
59
|
+
readonly _adapter?: ProviderAdapter;
|
|
60
|
+
/** Compaction summarizer adapter (defaults to `_adapter`/router). */
|
|
61
|
+
readonly _compactionAdapter?: ProviderAdapter;
|
|
62
|
+
/** Input stream override (defaults to process.stdin). */
|
|
63
|
+
readonly input?: NodeJS.ReadableStream;
|
|
64
|
+
/** Home directory for skill/command discovery isolation. */
|
|
65
|
+
readonly homeDir?: string;
|
|
66
|
+
/** Compaction sizing overrides (motivating-failure tests). */
|
|
67
|
+
readonly _compaction?: {
|
|
68
|
+
readonly contextLimit?: number;
|
|
69
|
+
readonly compactionThreshold?: number;
|
|
70
|
+
readonly snipKeepHead?: number;
|
|
71
|
+
readonly snipKeepTail?: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Run the conversational interview to completion: boots a persisted
|
|
76
|
+
* `runChatLoop` session with the continuity fabric on, lets the model
|
|
77
|
+
* interview the user (ask_user) and submit drafts (emit_spec, validated
|
|
78
|
+
* in-conversation), and returns the first spec that parses. The caller
|
|
79
|
+
* writes the YAML — this function performs no writes outside
|
|
80
|
+
* `<targetDir>/.crewhaus/`.
|
|
81
|
+
*
|
|
82
|
+
* Throws {@link InterviewIncompleteError} when the conversation ends
|
|
83
|
+
* (exit/EOF) without a validated spec, and lets `RunFailedError` from the
|
|
84
|
+
* runtime propagate so the CLI can render the classified FailureReport with
|
|
85
|
+
* {@link INIT_INTERVIEW_SAVED_NOTE}.
|
|
86
|
+
*/
|
|
87
|
+
export declare function runConversationalInterview(opts: ConversationalInterviewOptions): Promise<ConversationalInterviewResult>;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.3.0 §2.9 / PR 18 — the conversational `crewhaus init --interactive`
|
|
3
|
+
* (the scene of the release's motivating failure, rebuilt).
|
|
4
|
+
*
|
|
5
|
+
* The old model path was a single-shot forced `emit_spec` call: one stdin
|
|
6
|
+
* line, `toolChoice: { type: "tool" }`, extra typed lines silently
|
|
7
|
+
* discarded, nothing persisted. This module replaces it with a REAL
|
|
8
|
+
* conversation on `runChatLoop`:
|
|
9
|
+
*
|
|
10
|
+
* - TWO tools, NO forced toolChoice: `ask_user` (the question is surfaced
|
|
11
|
+
* on the terminal and the answer arrives as the next user message via
|
|
12
|
+
* the multi-turn REPL — nothing discarded) and `emit_spec` (the same
|
|
13
|
+
* `{ yaml }` contract as before; `parseSpec` failures return to the
|
|
14
|
+
* conversation as tool errors the model fixes IN-CONTEXT, not via blind
|
|
15
|
+
* re-attempts with an error ledger).
|
|
16
|
+
* - The continuity fabric is ON for the interview itself: `wireMemory`
|
|
17
|
+
* with a continuity fragment, spec-scoped under the TARGET directory as
|
|
18
|
+
* `init-<dirname>` — so REQ pinning (FocusWrite), the verbatim
|
|
19
|
+
* requirements ledger, compaction protection, and the teardown handoff
|
|
20
|
+
* all apply to the creator conversation.
|
|
21
|
+
* - The session is persisted and RESUMABLE: `--resume` replays the
|
|
22
|
+
* transcript, reseeds the ledger from `context_evicted` events, and a
|
|
23
|
+
* kick-off turn makes the first assistant message the resume summary
|
|
24
|
+
* ("Resuming: N requirements confirmed, M open questions …") — no work
|
|
25
|
+
* redone, no question re-asked.
|
|
26
|
+
* - A terminal failure (billing/auth/token exhaustion) propagates as the
|
|
27
|
+
* PR-3 `RunFailedError`; the CLI renders the FailureReport plus
|
|
28
|
+
* {@link INIT_INTERVIEW_SAVED_NOTE}.
|
|
29
|
+
*
|
|
30
|
+
* Testable by construction: the model adapter, the input stream, the home
|
|
31
|
+
* directory (skill discovery), and the compaction sizing are all injectable,
|
|
32
|
+
* so a mock adapter can drive fully scripted conversations.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
37
|
+
import { wireMemory } from "@crewhaus/memory-service";
|
|
38
|
+
import { BUILTIN_DEFAULT_RULES, } from "@crewhaus/permission-engine";
|
|
39
|
+
import { createRunContext } from "@crewhaus/run-context";
|
|
40
|
+
import { runChatLoop } from "@crewhaus/runtime-core";
|
|
41
|
+
import { createSessionStore } from "@crewhaus/session-store";
|
|
42
|
+
import { createSkillTool } from "@crewhaus/skills-registry";
|
|
43
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
44
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
45
|
+
import { z } from "zod";
|
|
46
|
+
import { ASK_USER_TOOL, EMIT_SPEC_TOOL, buildInterviewSystemPrompt } from "./init-interactive";
|
|
47
|
+
/** The saved-interview hint appended to every terminal-failure report and to
|
|
48
|
+
* the incomplete-interview error (design §2.9's exact promise). */
|
|
49
|
+
export const INIT_INTERVIEW_SAVED_NOTE = "Your interview is saved — `crewhaus init --interactive --resume` continues where it stopped.";
|
|
50
|
+
/** The synthetic kick-off turn a `--resume` boot runs so the FIRST assistant
|
|
51
|
+
* message is the resume summary — the user never re-types anything. */
|
|
52
|
+
export const RESUME_KICKOFF_MESSAGE = "Resume the interview from the saved session. Start your reply with the resume summary " +
|
|
53
|
+
'("Resuming: N requirements confirmed, M open questions …") computed from the requirements ' +
|
|
54
|
+
"ledger and focus, then continue where the interview stopped — never re-ask a confirmed requirement.";
|
|
55
|
+
/** The interview ended (exit/EOF/no emit) without a validated spec. Message
|
|
56
|
+
* carries the resume hint; `kind: "config"` keeps die()'s one-liner shape. */
|
|
57
|
+
export class InterviewIncompleteError extends CrewhausError {
|
|
58
|
+
name = "InterviewIncompleteError";
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super("config", message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* v0.3.0 §2.9 — TTY gate for the conversational path, mirroring the CLI's
|
|
65
|
+
* destructive-verb convention (`memory clear`, `state restore`): interactive
|
|
66
|
+
* conversation needs an interactive terminal, and `--yes` explicitly asks
|
|
67
|
+
* for the non-conversational path. Pure; `index.ts` threads the real
|
|
68
|
+
* `process.stdin.isTTY`.
|
|
69
|
+
*/
|
|
70
|
+
export function conversationalPathAllowed(opts) {
|
|
71
|
+
if (opts.assumeYes) {
|
|
72
|
+
return { allowed: false, reason: "--yes requested the scripted questionnaire" };
|
|
73
|
+
}
|
|
74
|
+
if (!opts.stdinIsTTY) {
|
|
75
|
+
return {
|
|
76
|
+
allowed: false,
|
|
77
|
+
reason: "the conversational interview needs an interactive terminal (TTY)",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { allowed: true };
|
|
81
|
+
}
|
|
82
|
+
/** Tools the interview pre-allows (flag-source `alwaysAllow`) so the REPL
|
|
83
|
+
* never stalls on an approval prompt mid-conversation. Deliberately NOT
|
|
84
|
+
* MemoryClear — destructive, and an interview has no business clearing
|
|
85
|
+
* stores; it stays behind the default ask gate. */
|
|
86
|
+
const INTERVIEW_ALLOWED_TOOLS = [
|
|
87
|
+
ASK_USER_TOOL.name,
|
|
88
|
+
EMIT_SPEC_TOOL.name,
|
|
89
|
+
"FocusRead",
|
|
90
|
+
"FocusWrite",
|
|
91
|
+
"PlanRead",
|
|
92
|
+
"PlanUpdate",
|
|
93
|
+
"PlanComplete",
|
|
94
|
+
"GoalWrite",
|
|
95
|
+
"GoalUpdate",
|
|
96
|
+
"GoalList",
|
|
97
|
+
"Skill",
|
|
98
|
+
];
|
|
99
|
+
function interviewRuleSet() {
|
|
100
|
+
const flag = INTERVIEW_ALLOWED_TOOLS.map((pattern) => ({
|
|
101
|
+
type: "alwaysAllow",
|
|
102
|
+
pattern,
|
|
103
|
+
source: "flag",
|
|
104
|
+
}));
|
|
105
|
+
return { flag, settings: [], yaml: [], hooks: [], builtin: [...BUILTIN_DEFAULT_RULES] };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Extract the REQ → spec-field mapping lines from the interview transcript:
|
|
109
|
+
* the LAST assistant message containing `REQ-nnn … →` lines wins (the
|
|
110
|
+
* discipline mandates the mapping in the reply that precedes `emit_spec`).
|
|
111
|
+
* Best-effort and pure — the CLI prints whatever the model actually
|
|
112
|
+
* produced; correctness of the written spec never depends on it.
|
|
113
|
+
*/
|
|
114
|
+
export function extractReqMappingLines(rawJsonl) {
|
|
115
|
+
const mapRe = /REQ-\d+.*(?:→|->)/;
|
|
116
|
+
let lines = [];
|
|
117
|
+
for (const line of rawJsonl.split("\n")) {
|
|
118
|
+
if (line.trim() === "")
|
|
119
|
+
continue;
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(line);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue; // torn tail line from a crashed writer
|
|
126
|
+
}
|
|
127
|
+
if (parsed.kind !== "assistant_message")
|
|
128
|
+
continue;
|
|
129
|
+
const content = parsed.payload?.content;
|
|
130
|
+
const text = typeof content === "string"
|
|
131
|
+
? content
|
|
132
|
+
: Array.isArray(content)
|
|
133
|
+
? content
|
|
134
|
+
.map((b) => {
|
|
135
|
+
const block = b;
|
|
136
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
|
137
|
+
})
|
|
138
|
+
.join("\n")
|
|
139
|
+
: "";
|
|
140
|
+
const matching = text
|
|
141
|
+
.split("\n")
|
|
142
|
+
.map((l) => l.trim())
|
|
143
|
+
.filter((l) => mapRe.test(l));
|
|
144
|
+
if (matching.length > 0)
|
|
145
|
+
lines = matching;
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Run the conversational interview to completion: boots a persisted
|
|
151
|
+
* `runChatLoop` session with the continuity fabric on, lets the model
|
|
152
|
+
* interview the user (ask_user) and submit drafts (emit_spec, validated
|
|
153
|
+
* in-conversation), and returns the first spec that parses. The caller
|
|
154
|
+
* writes the YAML — this function performs no writes outside
|
|
155
|
+
* `<targetDir>/.crewhaus/`.
|
|
156
|
+
*
|
|
157
|
+
* Throws {@link InterviewIncompleteError} when the conversation ends
|
|
158
|
+
* (exit/EOF) without a validated spec, and lets `RunFailedError` from the
|
|
159
|
+
* runtime propagate so the CLI can render the classified FailureReport with
|
|
160
|
+
* {@link INIT_INTERVIEW_SAVED_NOTE}.
|
|
161
|
+
*/
|
|
162
|
+
export async function runConversationalInterview(opts) {
|
|
163
|
+
const log = opts.log ?? ((line) => void process.stdout.write(line));
|
|
164
|
+
const sessionRootDir = join(opts.targetDir, ".crewhaus", "sessions");
|
|
165
|
+
// --resume resolves to the most-recently-updated persisted interview for
|
|
166
|
+
// this spec name — the same resolution `crewhaus run --continue` uses.
|
|
167
|
+
let resumeId;
|
|
168
|
+
if (opts.resume === true) {
|
|
169
|
+
const store = createSessionStore({ rootDir: sessionRootDir });
|
|
170
|
+
const sessions = await store.list();
|
|
171
|
+
const match = sessions.find((s) => s.name === opts.specName);
|
|
172
|
+
if (match === undefined) {
|
|
173
|
+
throw new InterviewIncompleteError(`no saved interview for "${opts.specName}" under ${sessionRootDir} — start one with: crewhaus init --interactive`);
|
|
174
|
+
}
|
|
175
|
+
resumeId = match.id;
|
|
176
|
+
log(`[interview] resuming session ${match.id} (last updated ${match.updatedAt})\n`);
|
|
177
|
+
}
|
|
178
|
+
// The abort controller is the loop's clean exit: once a spec validates,
|
|
179
|
+
// the turn_end subscriber aborts and the REPL breaks BEFORE prompting for
|
|
180
|
+
// another line (the in-flight turn — the model's closing confirmation —
|
|
181
|
+
// always completes first).
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const runContext = createRunContext({
|
|
184
|
+
abortSignal: controller.signal,
|
|
185
|
+
...(resumeId !== undefined ? { sessionId: resumeId } : {}),
|
|
186
|
+
});
|
|
187
|
+
const sessionId = runContext.sessionId;
|
|
188
|
+
// v0.3.0 §2.9 — continuity fabric ON for the interview itself: the SAME
|
|
189
|
+
// wireMemory composition-root call every harness makes, spec-scoped under
|
|
190
|
+
// the target directory. Registers Focus/Plan/Goal tools, returns the
|
|
191
|
+
// ledger/tail/handoff seam, and merges the builtin `continuity` skill +
|
|
192
|
+
// slash commands at lowest precedence.
|
|
193
|
+
const tools = [];
|
|
194
|
+
const wired = await wireMemory({ specName: opts.specName, continuity: {} }, {
|
|
195
|
+
catalog: {
|
|
196
|
+
register: (tool) => {
|
|
197
|
+
tools.push(tool);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
cwd: opts.targetDir,
|
|
201
|
+
sessionRootDir,
|
|
202
|
+
log,
|
|
203
|
+
...(opts.homeDir !== undefined ? { homeDir: opts.homeDir } : {}),
|
|
204
|
+
});
|
|
205
|
+
// The two interview tools — free-form conversation, no forced toolChoice.
|
|
206
|
+
let emitted;
|
|
207
|
+
tools.push(buildTool({
|
|
208
|
+
name: ASK_USER_TOOL.name,
|
|
209
|
+
description: ASK_USER_TOOL.description,
|
|
210
|
+
inputSchema: z.object({ question: z.string().min(1) }),
|
|
211
|
+
readOnly: true,
|
|
212
|
+
destructive: false,
|
|
213
|
+
concurrencySafe: false,
|
|
214
|
+
execute: async ({ question }) => {
|
|
215
|
+
// Surface the question distinctly; the answer arrives through the
|
|
216
|
+
// REPL's next `you>` line — multi-turn stdin, nothing discarded.
|
|
217
|
+
log(`\n[interviewer] ${question}\n`);
|
|
218
|
+
return ("Question delivered. End your turn now — the user's answer arrives as the " +
|
|
219
|
+
"next user message. Never re-ask a requirement already recorded as confirmed.");
|
|
220
|
+
},
|
|
221
|
+
}), buildTool({
|
|
222
|
+
name: EMIT_SPEC_TOOL.name,
|
|
223
|
+
description: EMIT_SPEC_TOOL.description,
|
|
224
|
+
inputSchema: z.object({ yaml: z.string().min(1) }),
|
|
225
|
+
readOnly: true,
|
|
226
|
+
destructive: false,
|
|
227
|
+
concurrencySafe: false,
|
|
228
|
+
execute: async ({ yaml }) => {
|
|
229
|
+
// parseSpec throws SpecParseError on an invalid draft; tool-executor
|
|
230
|
+
// turns the throw into an `is_error` tool result, so the validation
|
|
231
|
+
// error goes BACK into the conversation and the model revises
|
|
232
|
+
// in-context — the ledger and Q&A history stay in play.
|
|
233
|
+
const spec = parseSpec(yaml);
|
|
234
|
+
emitted = { yaml, spec };
|
|
235
|
+
return ("Spec validated against the live schema — the interview is complete and the " +
|
|
236
|
+
"CLI writes crewhaus.yaml next. Reply with a one-line confirmation.");
|
|
237
|
+
},
|
|
238
|
+
}));
|
|
239
|
+
const skills = wired.options.skills ?? [];
|
|
240
|
+
if (skills.length > 0)
|
|
241
|
+
tools.push(createSkillTool(skills));
|
|
242
|
+
const unsubscribe = runContext.eventBus.subscribe((event) => {
|
|
243
|
+
if (event.kind === "turn_end" && emitted !== undefined)
|
|
244
|
+
controller.abort();
|
|
245
|
+
});
|
|
246
|
+
const common = {
|
|
247
|
+
model: opts.model,
|
|
248
|
+
instructions: buildInterviewSystemPrompt(),
|
|
249
|
+
tools,
|
|
250
|
+
permissionMode: "default",
|
|
251
|
+
permissionRules: interviewRuleSet(),
|
|
252
|
+
runContext,
|
|
253
|
+
sessionRootDir,
|
|
254
|
+
sessionName: opts.specName,
|
|
255
|
+
sessionTarget: "init",
|
|
256
|
+
...(wired.options.continuity !== undefined ? { continuity: wired.options.continuity } : {}),
|
|
257
|
+
...(skills.length > 0 ? { skills } : {}),
|
|
258
|
+
...(wired.options.slashCommands !== undefined
|
|
259
|
+
? { slashCommands: wired.options.slashCommands }
|
|
260
|
+
: {}),
|
|
261
|
+
...(opts._adapter !== undefined ? { _adapter: opts._adapter } : {}),
|
|
262
|
+
...(opts._compactionAdapter !== undefined
|
|
263
|
+
? { _compactionAdapter: opts._compactionAdapter }
|
|
264
|
+
: {}),
|
|
265
|
+
...(opts._compaction ?? {}),
|
|
266
|
+
...(opts.input !== undefined ? { input: opts.input } : {}),
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
if (resumeId !== undefined) {
|
|
270
|
+
// Kick-off turn: replay the transcript + rebuild the ledger from the
|
|
271
|
+
// logged `context_evicted` events, then run ONE seeded turn so the
|
|
272
|
+
// first assistant message is the resume summary — the user types
|
|
273
|
+
// nothing to get back to where the interview stopped.
|
|
274
|
+
await runChatLoop({
|
|
275
|
+
...common,
|
|
276
|
+
singleTurn: true,
|
|
277
|
+
resume: { sessionId: resumeId },
|
|
278
|
+
seedMessages: [{ role: "user", content: RESUME_KICKOFF_MESSAGE }],
|
|
279
|
+
});
|
|
280
|
+
// Continue conversationally unless the kick-off already emitted the
|
|
281
|
+
// spec (everything was confirmed before the original session died).
|
|
282
|
+
if (emitted === undefined) {
|
|
283
|
+
await runChatLoop({ ...common, resume: { sessionId: resumeId } });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
await runChatLoop(common);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
unsubscribe();
|
|
292
|
+
}
|
|
293
|
+
if (emitted === undefined) {
|
|
294
|
+
throw new InterviewIncompleteError(`the interview ended without a validated spec — nothing was written. ${INIT_INTERVIEW_SAVED_NOTE}`);
|
|
295
|
+
}
|
|
296
|
+
// Best-effort: the REQ → spec-field mapping the model listed before
|
|
297
|
+
// emit_spec, read back from the persisted transcript.
|
|
298
|
+
let mappingLines = [];
|
|
299
|
+
try {
|
|
300
|
+
mappingLines = extractReqMappingLines(readFileSync(join(sessionRootDir, `${sessionId}.jsonl`), "utf-8"));
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// No transcript / unreadable — the mapping line is a bonus, never a gate.
|
|
304
|
+
}
|
|
305
|
+
return { yaml: emitted.yaml, spec: emitted.spec, sessionId, mappingLines };
|
|
306
|
+
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
import { type Spec
|
|
1
|
+
import { type Spec } from "@crewhaus/spec";
|
|
2
2
|
/**
|
|
3
|
-
* Item 39 — `crewhaus init --interactive`. Folds the demos
|
|
4
|
-
* pattern (an agent that interviews the user in plain
|
|
5
|
-
* validated crewhaus.yaml) into core.
|
|
3
|
+
* Item 39 / v0.3.0 §2.9 — `crewhaus init --interactive`. Folds the demos
|
|
4
|
+
* harness-designer pattern (an agent that interviews the user in plain
|
|
5
|
+
* English and emits a validated crewhaus.yaml) into core. Design commitments:
|
|
6
6
|
*
|
|
7
7
|
* 1. NO dependency on the demos repo. The shape-decision guidance is
|
|
8
8
|
* bundled inline here ({@link SHAPE_GUIDANCE} / {@link buildInterviewSystemPrompt}),
|
|
9
9
|
* and validation is `parseSpec` called in-process — the single source of
|
|
10
10
|
* truth for what compiles.
|
|
11
11
|
* 2. Every draft the model emits is validated against the LIVE Zod union via
|
|
12
|
-
* `parseSpec`, and on a structured error the error text
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* `parseSpec`, and on a structured error the error text goes back into
|
|
13
|
+
* the conversation as a tool error the model fixes in-context. So
|
|
14
|
+
* `init --interactive` cannot emit a spec that won't parse.
|
|
15
|
+
* 3. The interview is a REAL conversation (v0.3.0 PR 18): a `runChatLoop`
|
|
16
|
+
* session with `ask_user` + `emit_spec` and NO forced toolChoice —
|
|
17
|
+
* persisted, ledger-protected, and resumable. The conversational loop
|
|
18
|
+
* lives in `init-conversation.ts`; this module keeps the pure,
|
|
19
|
+
* side-effect-free pieces: the shape catalog, the interviewer system
|
|
20
|
+
* prompt, the tool contracts, and the credential-free fallback
|
|
21
|
+
* ({@link buildScriptedSpec} over stdin answers). The CLI wrapper in
|
|
22
|
+
* `index.ts` owns all I/O.
|
|
20
23
|
*/
|
|
21
24
|
/** The fourteen target shapes, with the one-line "pick me when" guidance the
|
|
22
25
|
* interviewer uses. Bundled inline so core never reaches into the demos repo. */
|
|
@@ -32,14 +35,17 @@ export declare const SCRIPTED_SHAPES: readonly ["cli", "workflow", "research"];
|
|
|
32
35
|
export type ScriptedShape = (typeof SCRIPTED_SHAPES)[number];
|
|
33
36
|
export declare function isScriptedShape(s: string): s is ScriptedShape;
|
|
34
37
|
/**
|
|
35
|
-
* The interviewer's system prompt
|
|
36
|
-
* the
|
|
38
|
+
* The interviewer's system prompt — a focused variant of the `continuity`
|
|
39
|
+
* discipline (v0.3.0 §2.9) on top of the shape catalog + the hard rules the
|
|
40
|
+
* emitted YAML must obey (single-line-safe names, the `$UPPER_SNAKE_CASE`
|
|
37
41
|
* secret convention, no `permissions.mode: bypass`). Kept as a builder so a
|
|
38
42
|
* test can assert the guidance is present without shipping a golden file.
|
|
39
43
|
*/
|
|
40
44
|
export declare function buildInterviewSystemPrompt(): string;
|
|
41
|
-
/** The tool the interviewer calls to submit a candidate spec
|
|
42
|
-
*
|
|
45
|
+
/** The tool the interviewer calls to submit a candidate spec — the SAME
|
|
46
|
+
* contract the pre-0.3.0 single-shot interview used (`{ yaml: string }`).
|
|
47
|
+
* Exported so tests can pin the schema and `init-conversation.ts` builds
|
|
48
|
+
* the RegisteredTool from it. */
|
|
43
49
|
export declare const EMIT_SPEC_TOOL: {
|
|
44
50
|
name: string;
|
|
45
51
|
description: string;
|
|
@@ -54,30 +60,24 @@ export declare const EMIT_SPEC_TOOL: {
|
|
|
54
60
|
required: string[];
|
|
55
61
|
};
|
|
56
62
|
};
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
/** v0.3.0 §2.9 — the interviewer's clarifying-question tool. The loop
|
|
64
|
+
* surfaces the question on the terminal; the user's answer arrives as the
|
|
65
|
+
* next user message through the multi-turn REPL (nothing typed is ever
|
|
66
|
+
* discarded — the exact failure the single-shot path had). */
|
|
67
|
+
export declare const ASK_USER_TOOL: {
|
|
68
|
+
name: string;
|
|
69
|
+
description: string;
|
|
70
|
+
input_schema: {
|
|
71
|
+
type: "object";
|
|
72
|
+
properties: {
|
|
73
|
+
question: {
|
|
74
|
+
type: "string";
|
|
75
|
+
description: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
required: string[];
|
|
79
|
+
};
|
|
66
80
|
};
|
|
67
|
-
export declare class InterviewError extends SpecParseError {
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Drive the validate-and-retry interview loop. Each attempt asks the injected
|
|
71
|
-
* `proposeSpec` for a draft, runs it through `parseSpec` (the LIVE union), and
|
|
72
|
-
* on a `SpecParseError` appends the error to the feedback log for the next
|
|
73
|
-
* attempt. Succeeds on the first draft that validates; throws
|
|
74
|
-
* {@link InterviewError} after `maxAttempts` failed drafts (or if the model
|
|
75
|
-
* declines to emit). Pure given its `proposeSpec` seam — no I/O.
|
|
76
|
-
*/
|
|
77
|
-
export declare function runInterview(opts: {
|
|
78
|
-
readonly proposeSpec: ProposeSpec;
|
|
79
|
-
readonly maxAttempts?: number;
|
|
80
|
-
}): Promise<InterviewResult>;
|
|
81
81
|
/** The answers the scripted (no-credentials) questionnaire collects. Every
|
|
82
82
|
* field is a plain string/list so the CLI can gather them over stdin and a
|
|
83
83
|
* test can drive {@link buildScriptedSpec} directly. */
|