@tea-agent/loop-agent 0.22.0 → 0.24.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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +1143 -0
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +68 -1
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — instruction skills whitelist (design §7.5 / plan D1).
|
|
3
|
+
*
|
|
4
|
+
* Hard rules (plan D1):
|
|
5
|
+
* - skills are READ-ONLY text context injected into the Chat system prompt;
|
|
6
|
+
* - skills are NEVER registered as executable tools;
|
|
7
|
+
* - the whitelist is a COMPILE-TIME constant (no runtime dir scan, no env override);
|
|
8
|
+
* - arbitrary pi extension / package tool / user-level ~/.pi/agent skill is
|
|
9
|
+
* never discovered or loaded (V12);
|
|
10
|
+
* - project `.agents/skills/` (init projection) is NOT injected (prevents
|
|
11
|
+
* target-repo injecting write instructions).
|
|
12
|
+
*
|
|
13
|
+
* Only the loop-agent repo's own audited skills are eligible. Workflow-specific
|
|
14
|
+
* skills are conditional on taskKind/workflow and loaded on demand (not in MVP
|
|
15
|
+
* operator-context default set).
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Operator-context instruction skills (plan D1, "operator-context 允许").
|
|
21
|
+
* These are read-only methodology skills. Loaded as text context for every
|
|
22
|
+
* Chat session by default.
|
|
23
|
+
*
|
|
24
|
+
* MUST stay a compile-time constant. Adding a skill requires an ADR / plan note.
|
|
25
|
+
*/
|
|
26
|
+
export const OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS = Object.freeze([
|
|
27
|
+
"grill-me",
|
|
28
|
+
"systematic-debugging",
|
|
29
|
+
"code-review-core",
|
|
30
|
+
"codebase-scout",
|
|
31
|
+
"verification-before-completion",
|
|
32
|
+
"requesting-code-review",
|
|
33
|
+
"analyze-product-requirements",
|
|
34
|
+
"analyze-product-dependencies",
|
|
35
|
+
"using-git-worktrees",
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Meta / governance skills that must NEVER be injected into Chat context
|
|
39
|
+
* (plan D1 "元 / 治理 skill"). Injecting these would let Chat rewrite its own
|
|
40
|
+
* governance. Listed explicitly so accidental allowlist edits are caught.
|
|
41
|
+
*/
|
|
42
|
+
export const OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS = Object.freeze([
|
|
43
|
+
"loop-agent",
|
|
44
|
+
"agent-worker",
|
|
45
|
+
"init-capability-evolution",
|
|
46
|
+
"ai-engineering-context",
|
|
47
|
+
"grill-with-docs",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Workflow-specific skills (plan D1 "workflow-specific 允许(条件)").
|
|
51
|
+
* Only injected when the corresponding taskKind / workflow is active.
|
|
52
|
+
* NOT loaded by default in MVP Chat.
|
|
53
|
+
*/
|
|
54
|
+
export const OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS = Object.freeze([
|
|
55
|
+
"frontend-implementation",
|
|
56
|
+
"frontend-review",
|
|
57
|
+
"frontend-verification",
|
|
58
|
+
"frontend-design-review",
|
|
59
|
+
"test-driven-development",
|
|
60
|
+
"webapp-testing",
|
|
61
|
+
"playwright-cli",
|
|
62
|
+
"playwright-cli-case-generator",
|
|
63
|
+
"browser-tools",
|
|
64
|
+
]);
|
|
65
|
+
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
66
|
+
const DENIED = new Set(OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
67
|
+
const CONDITIONAL = new Set(OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS.map((s) => s.toLowerCase()));
|
|
68
|
+
export function isOperatorChatInstructionSkillAllowed(name) {
|
|
69
|
+
return ALLOWED.has(name.trim().toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
export function isOperatorChatInstructionSkillDenied(name) {
|
|
72
|
+
return DENIED.has(name.trim().toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
export function isOperatorChatInstructionSkillConditional(name) {
|
|
75
|
+
return CONDITIONAL.has(name.trim().toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
export function classifyInstructionSkill(name, options) {
|
|
78
|
+
const n = name.trim().toLowerCase();
|
|
79
|
+
if (DENIED.has(n)) {
|
|
80
|
+
return { ok: false, reason: "denied-governance", name };
|
|
81
|
+
}
|
|
82
|
+
if (ALLOWED.has(n)) {
|
|
83
|
+
return { ok: true, kind: "operator-context", name };
|
|
84
|
+
}
|
|
85
|
+
if (CONDITIONAL.has(n)) {
|
|
86
|
+
const active = options?.activeWorkflowSkills ?? [];
|
|
87
|
+
const activeLower = new Set(active.map((s) => s.toLowerCase()));
|
|
88
|
+
if (activeLower.has(n)) {
|
|
89
|
+
// Conditional skill is allowed only when explicitly activated by workflow.
|
|
90
|
+
// For MVP, conditional skills are not auto-injected; callers must opt in.
|
|
91
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
94
|
+
}
|
|
95
|
+
return { ok: false, reason: "unknown", name };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a SKILL.md frontmatter description (first `description:` line) and
|
|
99
|
+
* return the body text (frontmatter stripped). Tolerant of missing frontmatter.
|
|
100
|
+
*/
|
|
101
|
+
export function parseSkillFrontmatter(raw) {
|
|
102
|
+
let description = "";
|
|
103
|
+
let bodyText = raw;
|
|
104
|
+
const hasFrontmatter = raw.trimStart().startsWith("---");
|
|
105
|
+
if (hasFrontmatter) {
|
|
106
|
+
const lines = raw.split(/\r?\n/);
|
|
107
|
+
// find closing ---
|
|
108
|
+
let closeIdx = -1;
|
|
109
|
+
for (let i = 1; i < lines.length; i++) {
|
|
110
|
+
if (lines[i].trim() === "---") {
|
|
111
|
+
closeIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (closeIdx > 0) {
|
|
116
|
+
const fm = lines.slice(1, closeIdx).join("\n");
|
|
117
|
+
bodyText = lines.slice(closeIdx + 1).join("\n").trim();
|
|
118
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m);
|
|
119
|
+
if (descMatch) {
|
|
120
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
bodyText = raw.trim();
|
|
126
|
+
}
|
|
127
|
+
return { description, bodyText };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Load the operator-context instruction skills from the loop-agent repo's own
|
|
131
|
+
* `skills/` directory and return their text bodies for system-prompt injection.
|
|
132
|
+
*
|
|
133
|
+
* Security: only names in OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS are read;
|
|
134
|
+
* any other path is rejected before filesystem access (safe-side).
|
|
135
|
+
*
|
|
136
|
+
* @param skillsDir absolute path to the loop-agent repo `skills/` directory
|
|
137
|
+
* (source repo / npm pack asset, NOT target project `.agents/skills/`).
|
|
138
|
+
* @param names override list (tests); defaults to the full allowlist.
|
|
139
|
+
*/
|
|
140
|
+
export async function loadOperatorChatInstructionSkills(skillsDir, names) {
|
|
141
|
+
const selected = names ?? OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS;
|
|
142
|
+
const loaded = [];
|
|
143
|
+
const failures = [];
|
|
144
|
+
const skipped = [];
|
|
145
|
+
for (const rawName of selected) {
|
|
146
|
+
const name = rawName.trim();
|
|
147
|
+
if (!isOperatorChatInstructionSkillAllowed(name)) {
|
|
148
|
+
skipped.push(name);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Defense in depth: reject path traversal / governance skills even if
|
|
152
|
+
// an attacker managed to mutate the allowlist at runtime.
|
|
153
|
+
if (isOperatorChatInstructionSkillDenied(name)) {
|
|
154
|
+
skipped.push(name);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const skillPath = path.join(skillsDir, name, "SKILL.md");
|
|
158
|
+
const resolved = path.resolve(skillPath);
|
|
159
|
+
const resolvedSkillsDir = path.resolve(skillsDir);
|
|
160
|
+
if (!resolved.startsWith(resolvedSkillsDir + path.sep)) {
|
|
161
|
+
failures.push({
|
|
162
|
+
name,
|
|
163
|
+
message: "path escapes skills directory",
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const raw = await readFile(resolved, "utf8");
|
|
169
|
+
const { description, bodyText } = parseSkillFrontmatter(raw);
|
|
170
|
+
loaded.push({
|
|
171
|
+
name,
|
|
172
|
+
description,
|
|
173
|
+
bodyText,
|
|
174
|
+
charCount: bodyText.length,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
failures.push({
|
|
179
|
+
name,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { loaded, failures, skipped };
|
|
185
|
+
}
|
|
186
|
+
/** Hard cap on total injected instruction-skill chars (token budget guard). */
|
|
187
|
+
export const OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET = 24_000;
|
|
188
|
+
/**
|
|
189
|
+
* Compose the system-prompt fragment listing loaded instruction skills.
|
|
190
|
+
* Each skill is rendered as a bounded text block. If total chars exceed the
|
|
191
|
+
* budget, later skills are truncated (operator-context set is small enough
|
|
192
|
+
* that truncation should not trigger in practice, but the cap is enforced).
|
|
193
|
+
*/
|
|
194
|
+
export function composeInstructionSkillsPrompt(skills, budget = OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET) {
|
|
195
|
+
if (skills.length === 0)
|
|
196
|
+
return "";
|
|
197
|
+
const lines = [
|
|
198
|
+
"## Operator methodology context (read-only instruction skills)",
|
|
199
|
+
"",
|
|
200
|
+
"These skills are READ-ONLY context. You may reference their methodology when reasoning, but you CANNOT execute them. All actions must go through the registered operator tools.",
|
|
201
|
+
"",
|
|
202
|
+
];
|
|
203
|
+
let used = 0;
|
|
204
|
+
for (const skill of skills) {
|
|
205
|
+
const header = `\n### ${skill.name}${skill.description ? ` — ${skill.description}` : ""}\n`;
|
|
206
|
+
const remaining = budget - used - header.length;
|
|
207
|
+
if (remaining <= 0) {
|
|
208
|
+
lines.push(`_[${skill.name}: omitted, char budget exhausted]_`);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const slice = skill.bodyText.slice(0, Math.max(0, remaining));
|
|
212
|
+
lines.push(header);
|
|
213
|
+
lines.push(slice);
|
|
214
|
+
used += header.length + slice.length;
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { AssessmentStore, isAssessmentFreshForDraft } from "../interview/assessment.js";
|
|
2
|
+
import { applyGrillMeAnswer, emptyDraft, isDraftStructurallyComplete, listDraftGaps, nextGrillMeQuestion } from "../interview/grill-me.js";
|
|
3
|
+
import { InterviewSessionStore } from "../interview/session.js";
|
|
4
|
+
import { DraftStore } from "../draft-store.js";
|
|
5
|
+
import { normalizeConsoleWorkflowKind } from "../workflow-kinds.js";
|
|
6
|
+
import { scrubSecrets } from "./explore-tools.js";
|
|
7
|
+
function redact(value) {
|
|
8
|
+
const raw = JSON.stringify(value);
|
|
9
|
+
return JSON.parse(scrubSecrets(raw).scrubbed);
|
|
10
|
+
}
|
|
11
|
+
/** Browser/model-safe projection: strips app-data paths and scrubs secret-shaped content. */
|
|
12
|
+
export function projectChatInterviewState(state) {
|
|
13
|
+
return redact({
|
|
14
|
+
interview: state.interview,
|
|
15
|
+
draft: { draft: state.draft.draft, draftSha256: state.draft.draftSha256 },
|
|
16
|
+
...(state.question ? { question: state.question } : {}),
|
|
17
|
+
...(state.taskKindRecommendation ? { taskKindRecommendation: state.taskKindRecommendation } : {}),
|
|
18
|
+
assessment: state.assessment,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export class ChatInterviewAdapter {
|
|
22
|
+
appData;
|
|
23
|
+
events;
|
|
24
|
+
interviews;
|
|
25
|
+
drafts;
|
|
26
|
+
assessments;
|
|
27
|
+
constructor(appData, events) {
|
|
28
|
+
this.appData = appData;
|
|
29
|
+
this.events = events;
|
|
30
|
+
this.interviews = new InterviewSessionStore(appData);
|
|
31
|
+
this.drafts = new DraftStore(appData);
|
|
32
|
+
this.assessments = new AssessmentStore(appData);
|
|
33
|
+
}
|
|
34
|
+
append(operatorSessionId, turnId, kind, data) {
|
|
35
|
+
this.events.append(operatorSessionId, turnId, { kind, data: redact(data) });
|
|
36
|
+
}
|
|
37
|
+
assessment(draft, assessmentId, fresh = false, reason) {
|
|
38
|
+
const missingFields = listDraftGaps(draft.draft);
|
|
39
|
+
return { outcome: missingFields.length === 0 ? "complete" : "questions-required", fresh, missingFields, ...(reason ? { reason } : {}), ...(assessmentId ? { assessmentId } : {}), draftSha256: draft.draftSha256 };
|
|
40
|
+
}
|
|
41
|
+
async start(input) {
|
|
42
|
+
const interview = await this.interviews.create({ taskId: input.taskId, operatorSessionId: input.operatorSessionId });
|
|
43
|
+
const base = emptyDraft(input.taskId, input.title);
|
|
44
|
+
const draft = await this.drafts.save({ ...base, ...input.initialDraft, requirement: { ...base.requirement, ...input.initialDraft?.requirement }, constraints: { ...base.constraints, ...input.initialDraft?.constraints }, verification: { ...base.verification, ...input.initialDraft?.verification }, schemaVersion: 1, taskId: input.taskId });
|
|
45
|
+
const question = nextGrillMeQuestion(draft.draft);
|
|
46
|
+
const savedInterview = await this.interviews.setCurrentQuestion(interview.sessionId, question ? { id: question.id, text: question.text, affectsFields: question.affectsFields, risk: question.risk } : undefined, question ? "awaiting-user-answer" : "draft-ready");
|
|
47
|
+
this.append(input.operatorSessionId, interview.sessionId, "interview-turn", { interviewSessionId: interview.sessionId, taskId: input.taskId, state: savedInterview.state, ...(question ? { question } : {}) });
|
|
48
|
+
this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: input.taskId, draft: draft.draft, draftSha256: draft.draftSha256 });
|
|
49
|
+
const assessment = this.assessment(draft);
|
|
50
|
+
this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: input.taskId, ...assessment });
|
|
51
|
+
return { interview: savedInterview, draft, question, assessment };
|
|
52
|
+
}
|
|
53
|
+
async answer(input) {
|
|
54
|
+
const interview = await this.interviews.get(input.interviewSessionId);
|
|
55
|
+
if (!interview || interview.operatorSessionId !== input.operatorSessionId)
|
|
56
|
+
throw new Error("interview session not found for Chat session");
|
|
57
|
+
if (!interview.currentQuestion || interview.currentQuestion.id !== input.questionId)
|
|
58
|
+
throw new Error("answer does not match current open question");
|
|
59
|
+
const current = await this.drafts.get(interview.taskId);
|
|
60
|
+
if (!current)
|
|
61
|
+
throw new Error(`draft not found: ${interview.taskId}`);
|
|
62
|
+
const question = nextGrillMeQuestion(current.draft);
|
|
63
|
+
if (!question || question.id !== input.questionId)
|
|
64
|
+
throw new Error("draft gap does not match current question");
|
|
65
|
+
const applied = applyGrillMeAnswer(current.draft, question, { response: input.response, text: input.text });
|
|
66
|
+
const isTaskKind = question.gapId === "taskKind";
|
|
67
|
+
const recommendation = isTaskKind ? applied.taskKind : undefined;
|
|
68
|
+
if (isTaskKind)
|
|
69
|
+
delete applied.taskKind;
|
|
70
|
+
const draft = await this.drafts.save(applied);
|
|
71
|
+
await this.interviews.recordAnswer(interview.sessionId, { questionId: input.questionId, response: input.response, ...(input.text ? { text: input.text } : {}), at: new Date().toISOString() });
|
|
72
|
+
const projected = recommendation ? { ...draft.draft, taskKind: recommendation } : draft.draft;
|
|
73
|
+
const nextQuestion = nextGrillMeQuestion(projected);
|
|
74
|
+
const savedInterview = await this.interviews.setCurrentQuestion(interview.sessionId, nextQuestion ? { id: nextQuestion.id, text: nextQuestion.text, affectsFields: nextQuestion.affectsFields, risk: nextQuestion.risk } : undefined, nextQuestion ? "awaiting-user-answer" : "awaiting-confirmation");
|
|
75
|
+
this.append(input.operatorSessionId, interview.sessionId, "interview-turn", { interviewSessionId: interview.sessionId, taskId: interview.taskId, state: savedInterview.state, answer: { questionId: input.questionId, response: input.response, ...(input.text ? { text: input.text } : {}) }, ...(nextQuestion ? { question: nextQuestion } : {}) });
|
|
76
|
+
this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: interview.taskId, draft: draft.draft, draftSha256: draft.draftSha256, ...(recommendation ? { taskKindRecommendation: recommendation } : {}) });
|
|
77
|
+
const assessment = this.assessment({ ...draft, draft: projected });
|
|
78
|
+
this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: interview.taskId, ...assessment });
|
|
79
|
+
return { interview: savedInterview, draft, question: nextQuestion, taskKindRecommendation: recommendation, assessment };
|
|
80
|
+
}
|
|
81
|
+
async confirmTaskKind(input) {
|
|
82
|
+
const interview = await this.interviews.get(input.interviewSessionId);
|
|
83
|
+
if (!interview || interview.operatorSessionId !== input.operatorSessionId)
|
|
84
|
+
throw new Error("interview session not found for Chat session");
|
|
85
|
+
const current = await this.drafts.get(interview.taskId);
|
|
86
|
+
if (!current)
|
|
87
|
+
throw new Error(`draft not found: ${interview.taskId}`);
|
|
88
|
+
const taskKind = normalizeConsoleWorkflowKind(input.taskKind, "standard");
|
|
89
|
+
// Save the taskKind change first so we have a stable draftSha256 to bind the
|
|
90
|
+
// assessment against (isAssessmentFreshForDraft compares draftSha256).
|
|
91
|
+
const draft = await this.drafts.save({ ...current.draft, taskKind });
|
|
92
|
+
const outcome = isDraftStructurallyComplete(draft.draft) ? "complete" : "questions-required";
|
|
93
|
+
const assessmentRef = await this.assessments.create({ draft: draft.draft, instructionVersion: interview.instructionVersion, provider: "deterministic", model: "grill-me", sessionId: interview.sessionId, turnId: interview.sessionId, outcome });
|
|
94
|
+
// Write the assessmentRef back into the draft so contractApply can resolve a
|
|
95
|
+
// fresh assessment from the DraftStore (operator-actions contractApply reads
|
|
96
|
+
// draft.interviewAssessmentRef.assessmentId). computeDraftSha256 excludes
|
|
97
|
+
// interviewAssessmentRef, so this does not churn the hash that certified it.
|
|
98
|
+
const draftWithRef = await this.drafts.save({ ...draft.draft, interviewAssessmentRef: { assessmentId: assessmentRef.assessmentId, draftSha256: draft.draftSha256 } });
|
|
99
|
+
const freshness = isAssessmentFreshForDraft(assessmentRef, draftWithRef.draft);
|
|
100
|
+
let savedInterview = await this.interviews.markDraftReady(interview.sessionId, draftWithRef.draft, draftWithRef.draftSha256);
|
|
101
|
+
savedInterview = await this.interviews.markAwaitingConfirmation(interview.sessionId, assessmentRef.assessmentId);
|
|
102
|
+
const assessment = this.assessment(draftWithRef, assessmentRef.assessmentId, freshness.fresh, freshness.fresh ? undefined : freshness.reason);
|
|
103
|
+
this.append(input.operatorSessionId, interview.sessionId, "taskKind-confirmed", { taskId: interview.taskId, taskKind, confirmedAt: new Date().toISOString(), confirmedBy: "human" });
|
|
104
|
+
this.append(input.operatorSessionId, interview.sessionId, "draft", { taskId: interview.taskId, draft: draftWithRef.draft, draftSha256: draftWithRef.draftSha256 });
|
|
105
|
+
this.append(input.operatorSessionId, interview.sessionId, "assessment", { taskId: interview.taskId, ...assessment });
|
|
106
|
+
return { interview: savedInterview, draft: draftWithRef, assessment };
|
|
107
|
+
}
|
|
108
|
+
async get(interviewSessionId) {
|
|
109
|
+
const interview = await this.interviews.get(interviewSessionId);
|
|
110
|
+
if (!interview)
|
|
111
|
+
return undefined;
|
|
112
|
+
const draft = await this.drafts.get(interview.taskId);
|
|
113
|
+
if (!draft)
|
|
114
|
+
return undefined;
|
|
115
|
+
const question = nextGrillMeQuestion(draft.draft);
|
|
116
|
+
let fresh = false;
|
|
117
|
+
let reason;
|
|
118
|
+
if (interview.assessmentId) {
|
|
119
|
+
const ref = await this.assessments.get(interview.assessmentId);
|
|
120
|
+
if (ref) {
|
|
121
|
+
const result = isAssessmentFreshForDraft(ref, draft.draft);
|
|
122
|
+
fresh = result.fresh;
|
|
123
|
+
if (!result.fresh)
|
|
124
|
+
reason = result.reason;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { interview, draft, question, assessment: this.assessment(draft, interview.assessmentId, fresh, reason) };
|
|
128
|
+
}
|
|
129
|
+
restore(operatorSessionId) {
|
|
130
|
+
const snapshot = this.events.snapshot(operatorSessionId);
|
|
131
|
+
if (!snapshot.length)
|
|
132
|
+
return undefined;
|
|
133
|
+
const latest = (kind) => [...snapshot].reverse().find((event) => event.kind === kind)?.data;
|
|
134
|
+
return { interview: latest("interview-turn"), draft: latest("draft")?.draft, assessment: latest("assessment"), taskKind: latest("taskKind-confirmed") };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — default model resolver (no hardcoded model).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the Chat default model from the repo harness.json `executors.pi.MED`
|
|
5
|
+
* tier, then finds a concrete provider by searching the Pi SDK's
|
|
6
|
+
* `snapshot.available` model list (preferring providers whose model entry is
|
|
7
|
+
* backed by credentials).
|
|
8
|
+
*
|
|
9
|
+
* Rationale: harness.json only stores a modelId per tier (e.g. "gpt-5.5"), not
|
|
10
|
+
* a provider. The Pi SDK `getModel(provider, modelId)` requires a provider, so
|
|
11
|
+
* we search `available` for entries whose `id` matches the modelId and pick the
|
|
12
|
+
* first whose provider resolves to a configured/credentialled source. This
|
|
13
|
+
* mirrors how the user's wizard-local / configured providers surface models.
|
|
14
|
+
*
|
|
15
|
+
* This module is deliberately framework-light: it takes a repoRoot + an
|
|
16
|
+
* optional model-list snapshot and returns a `{provider, modelId}` descriptor.
|
|
17
|
+
* The Pi runtime layer turns that into a concrete SDK model via getModel.
|
|
18
|
+
*/
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
/** Tier keys in harness executors.pi that may override the default. */
|
|
22
|
+
const EXECUTOR_TIERS = ["LOW", "MED", "HIGH"];
|
|
23
|
+
/**
|
|
24
|
+
* Read executors.pi.<tier> from the repo harness.json. Returns undefined when
|
|
25
|
+
* harness.json is absent or the tier is unset / "default" sentinel.
|
|
26
|
+
*
|
|
27
|
+
* Default tier = MED (matches DAG executor model matrix convention).
|
|
28
|
+
*/
|
|
29
|
+
export async function readHarnessPiModelTier(repoRoot, tier = "MED") {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(path.join(repoRoot, "harness.json"), "utf-8");
|
|
32
|
+
const manifest = JSON.parse(raw);
|
|
33
|
+
const pi = manifest.executors?.pi;
|
|
34
|
+
if (!pi)
|
|
35
|
+
return undefined;
|
|
36
|
+
const tierValue = pi[tier];
|
|
37
|
+
if (tierValue && tierValue !== "default")
|
|
38
|
+
return tierValue;
|
|
39
|
+
const defaultModel = pi.defaultModel;
|
|
40
|
+
if (defaultModel && defaultModel !== "default")
|
|
41
|
+
return defaultModel;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Search the SDK's available-model list for entries matching a modelId.
|
|
50
|
+
*
|
|
51
|
+
* Matching rule: an entry matches when its `id` equals the modelId OR equals
|
|
52
|
+
* `<provider>/<modelId>` (SDK lists both bare and provider-prefixed ids).
|
|
53
|
+
*
|
|
54
|
+
* Provider preference (in order):
|
|
55
|
+
* 1. Bare-id entries (`id === modelId`) — these are the user's own provider
|
|
56
|
+
* config (e.g. wizard-local registering `gpt-5.5`), preferred over
|
|
57
|
+
* aggregator reseller entries (`openai/gpt-5.5` from openrouter).
|
|
58
|
+
* 2. Among matches, prefer those flagged hasCredentials=true (provider is
|
|
59
|
+
* in the SDK's configuredProviders set).
|
|
60
|
+
* 3. Stable list order otherwise.
|
|
61
|
+
*/
|
|
62
|
+
export function findProviderForModelId(modelId, available) {
|
|
63
|
+
const matches = available.filter((m) => {
|
|
64
|
+
if (m.id === modelId)
|
|
65
|
+
return true;
|
|
66
|
+
if (m.id === `${m.provider}/${modelId}`)
|
|
67
|
+
return true;
|
|
68
|
+
const slash = m.id.lastIndexOf("/");
|
|
69
|
+
if (slash >= 0 && m.id.slice(slash + 1) === modelId)
|
|
70
|
+
return true;
|
|
71
|
+
return false;
|
|
72
|
+
});
|
|
73
|
+
if (matches.length === 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
// Score: bare-id (0) beats prefixed (1); then hasCredentials wins; then order.
|
|
76
|
+
const score = (m) => {
|
|
77
|
+
let s = 0;
|
|
78
|
+
if (m.id !== modelId)
|
|
79
|
+
s += 1; // prefixed id is less preferred
|
|
80
|
+
if (m.hasCredentials !== true)
|
|
81
|
+
s += 2; // no credentials is less preferred
|
|
82
|
+
return s;
|
|
83
|
+
};
|
|
84
|
+
return [...matches].sort((a, b) => score(a) - score(b))[0];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the Chat default model descriptor. Returns undefined when no model
|
|
88
|
+
* can be resolved (harness unset + no snapshot match).
|
|
89
|
+
*
|
|
90
|
+
* @param repoRoot repo with harness.json
|
|
91
|
+
* @param available SDK snapshot.available (already mapped to AvailableModelEntry)
|
|
92
|
+
* @param options.tier harness tier to read (default MED)
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveDefaultChatModel(repoRoot, available, options) {
|
|
95
|
+
const modelId = await readHarnessPiModelTier(repoRoot, options?.tier ?? "MED");
|
|
96
|
+
if (!modelId)
|
|
97
|
+
return undefined;
|
|
98
|
+
const entry = findProviderForModelId(modelId, available);
|
|
99
|
+
if (!entry)
|
|
100
|
+
return undefined;
|
|
101
|
+
return {
|
|
102
|
+
provider: entry.provider,
|
|
103
|
+
modelId,
|
|
104
|
+
label: entry.name,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function mergeOperationCardState(current, incoming) {
|
|
2
|
+
const index = current.findIndex((operation) => operation.operationId === incoming.operationId);
|
|
3
|
+
if (index < 0)
|
|
4
|
+
return [...current, incoming];
|
|
5
|
+
return current.map((operation, candidateIndex) => candidateIndex === index ? { ...operation, ...incoming } : operation);
|
|
6
|
+
}
|
|
7
|
+
export function operationFromEventPayload(payload) {
|
|
8
|
+
const operation = payload.operation;
|
|
9
|
+
if (!operation || typeof operation !== "object")
|
|
10
|
+
return undefined;
|
|
11
|
+
const candidate = operation;
|
|
12
|
+
if (candidate.schemaVersion !== 1 ||
|
|
13
|
+
typeof candidate.operationId !== "string" ||
|
|
14
|
+
typeof candidate.action !== "string" ||
|
|
15
|
+
typeof candidate.state !== "string" ||
|
|
16
|
+
typeof candidate.inspectHref !== "string" ||
|
|
17
|
+
typeof candidate.createdAt !== "string" ||
|
|
18
|
+
typeof candidate.updatedAt !== "string" ||
|
|
19
|
+
typeof candidate.previewHash !== "string") {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { open, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readJsonIfExists, writeSecureJson } from "../app-data.js";
|
|
5
|
+
import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS } from "./instruction-skills.js";
|
|
6
|
+
const revision = (raw) => createHash("sha256").update(raw).digest("hex");
|
|
7
|
+
export async function readModelsConfig(agentDir) {
|
|
8
|
+
let raw = "{}";
|
|
9
|
+
try {
|
|
10
|
+
raw = await readFile(path.join(agentDir, "models.json"), "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
/* empty config */
|
|
14
|
+
}
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return {
|
|
17
|
+
revision: revision(raw),
|
|
18
|
+
providers: Object.entries(parsed.providers ?? {}).map(([name, provider]) => ({
|
|
19
|
+
name,
|
|
20
|
+
api: typeof provider.api === "string" ? provider.api : undefined,
|
|
21
|
+
baseUrl: typeof provider.baseUrl === "string" ? provider.baseUrl : undefined,
|
|
22
|
+
credential: {
|
|
23
|
+
configured: typeof provider.apiKey === "string" && provider.apiKey.length > 0,
|
|
24
|
+
},
|
|
25
|
+
models: Array.isArray(provider.models)
|
|
26
|
+
? provider.models
|
|
27
|
+
.map((model) => {
|
|
28
|
+
const item = model && typeof model === "object"
|
|
29
|
+
? model
|
|
30
|
+
: {};
|
|
31
|
+
return {
|
|
32
|
+
id: String(item.id ?? ""),
|
|
33
|
+
name: typeof item.name === "string" ? item.name : undefined,
|
|
34
|
+
};
|
|
35
|
+
})
|
|
36
|
+
.filter((model) => model.id)
|
|
37
|
+
: [],
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export async function patchModelsConfig(agentDir, input) {
|
|
42
|
+
if (!["upsert-model", "remove-model", "remove-provider"].includes(input.action))
|
|
43
|
+
throw Object.assign(new Error("invalid models config action"), {
|
|
44
|
+
code: "INVALID_INPUT",
|
|
45
|
+
status: 400,
|
|
46
|
+
});
|
|
47
|
+
const file = path.join(agentDir, "models.json");
|
|
48
|
+
let raw = "{}";
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* create below */
|
|
54
|
+
}
|
|
55
|
+
if (revision(raw) !== input.expectedRevision)
|
|
56
|
+
throw Object.assign(new Error("models config changed"), {
|
|
57
|
+
code: "CONFIG_CONFLICT",
|
|
58
|
+
status: 409,
|
|
59
|
+
});
|
|
60
|
+
if (!/^[A-Za-z0-9._-]+$/.test(input.provider))
|
|
61
|
+
throw Object.assign(new Error("invalid provider"), {
|
|
62
|
+
code: "INVALID_INPUT",
|
|
63
|
+
status: 400,
|
|
64
|
+
});
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
parsed.providers ??= {};
|
|
67
|
+
const provider = parsed.providers[input.provider];
|
|
68
|
+
if (input.action === "remove-provider")
|
|
69
|
+
delete parsed.providers[input.provider];
|
|
70
|
+
else {
|
|
71
|
+
if (!provider)
|
|
72
|
+
throw Object.assign(new Error("provider not found; configure credentials with Pi CLI"), { code: "NOT_FOUND", status: 404 });
|
|
73
|
+
const models = Array.isArray(provider.models)
|
|
74
|
+
? provider.models.filter((model) => model && typeof model === "object")
|
|
75
|
+
: [];
|
|
76
|
+
if (!input.model?.id || input.model.id.length > 160)
|
|
77
|
+
throw Object.assign(new Error("model id is required"), {
|
|
78
|
+
code: "INVALID_INPUT",
|
|
79
|
+
status: 400,
|
|
80
|
+
});
|
|
81
|
+
if (input.model.name && input.model.name.length > 160)
|
|
82
|
+
throw Object.assign(new Error("model name is too long"), {
|
|
83
|
+
code: "INVALID_INPUT",
|
|
84
|
+
status: 400,
|
|
85
|
+
});
|
|
86
|
+
provider.models =
|
|
87
|
+
input.action === "remove-model"
|
|
88
|
+
? models.filter((model) => model.id !== input.model.id)
|
|
89
|
+
: [
|
|
90
|
+
...models.filter((model) => model.id !== input.model.id),
|
|
91
|
+
{
|
|
92
|
+
...(models.find((model) => model.id === input.model.id) ?? {}),
|
|
93
|
+
id: input.model.id,
|
|
94
|
+
...(input.model.name !== undefined
|
|
95
|
+
? { name: input.model.name }
|
|
96
|
+
: {}),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
const body = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
101
|
+
const tmp = path.join(agentDir, `.models.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
102
|
+
await writeFile(tmp, body, { encoding: "utf8", mode: 0o600 });
|
|
103
|
+
const handle = await open(tmp, "r+");
|
|
104
|
+
try {
|
|
105
|
+
await handle.sync();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
await rename(tmp, file);
|
|
111
|
+
return readModelsConfig(agentDir);
|
|
112
|
+
}
|
|
113
|
+
const preferencesFile = (appData) => path.join(appData.chats, ".instruction-skill-preferences.json");
|
|
114
|
+
export async function readSkillPreferences(appData) {
|
|
115
|
+
const value = await readJsonIfExists(preferencesFile(appData));
|
|
116
|
+
return (value?.disabledNames ?? []).filter((name) => OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.includes(name));
|
|
117
|
+
}
|
|
118
|
+
export async function writeSkillPreferences(appData, disabledNames) {
|
|
119
|
+
const allowed = new Set(OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS);
|
|
120
|
+
if (disabledNames.some((name) => !allowed.has(name)))
|
|
121
|
+
throw Object.assign(new Error("preferences contain a non-allowed skill"), {
|
|
122
|
+
code: "INVALID_INPUT",
|
|
123
|
+
status: 400,
|
|
124
|
+
});
|
|
125
|
+
const normalized = [...new Set(disabledNames)].sort();
|
|
126
|
+
await writeSecureJson(preferencesFile(appData), {
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
disabledNames: normalized,
|
|
129
|
+
});
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
export async function readPackageInventory(agentDir, repoRoot) {
|
|
133
|
+
const read = async (file) => {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
136
|
+
return (parsed.packages ?? []).filter((item) => typeof item === "string");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const global = await read(path.join(agentDir, "settings.json"));
|
|
143
|
+
const project = await read(path.join(repoRoot, ".pi", "settings.json"));
|
|
144
|
+
return [
|
|
145
|
+
...global.map((spec) => ({
|
|
146
|
+
spec,
|
|
147
|
+
scope: "global",
|
|
148
|
+
enabled: true,
|
|
149
|
+
resourceCount: 0,
|
|
150
|
+
})),
|
|
151
|
+
...project.map((spec) => ({
|
|
152
|
+
spec,
|
|
153
|
+
scope: "project",
|
|
154
|
+
enabled: true,
|
|
155
|
+
resourceCount: 0,
|
|
156
|
+
})),
|
|
157
|
+
];
|
|
158
|
+
}
|