@worca/app 1.2.0 → 1.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/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/core/onboarding.mjs
|
|
2
|
+
// The Getting-started checklist's DERIVED state (docs/getting-started.md).
|
|
3
|
+
// Every tick is computed from product state — the store, the settings file, the
|
|
4
|
+
// PATH — and never written by the client: finishing a step ticks it on its own,
|
|
5
|
+
// Restart / Show again are trivially safe, and an established install meets a
|
|
6
|
+
// checklist that already knows what it has done. The only stored flags are the
|
|
7
|
+
// two in settings.mjs#onboardingPrefs (hidden, welcomeSeen).
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { isAbsolute, join } from 'node:path';
|
|
10
|
+
import { getDb } from './db.mjs';
|
|
11
|
+
import { countProjects } from './projects.mjs';
|
|
12
|
+
import { countWorkspaces } from './workspaces.mjs';
|
|
13
|
+
import { countThreads } from './ask/store.mjs';
|
|
14
|
+
import { listScopes } from './metrics/read.mjs';
|
|
15
|
+
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
16
|
+
import { onboardingPrefs } from './settings.mjs';
|
|
17
|
+
|
|
18
|
+
/** Step ids in shelf order. The UI (ui/public/getting-started.mjs) carries the
|
|
19
|
+
* copy and artwork for each; this list is the contract between the two. */
|
|
20
|
+
export const ONBOARDING_STEPS = Object.freeze([
|
|
21
|
+
'claude', 'project', 'run', 'ask', 'workflows', 'realRun', 'workspace', 'teamMetrics',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/** The configured Claude binary — the same precedence claude-runner.mjs spawns with. */
|
|
25
|
+
export function configuredClaudeBin() {
|
|
26
|
+
return process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const hasSep = (s) => s.includes('/') || s.includes('\\');
|
|
30
|
+
|
|
31
|
+
/** First PATH hit for `name` (with `exts` tried in order), or null. Pure: PATH and
|
|
32
|
+
* the existence probe are injected. */
|
|
33
|
+
function findOnPath(name, pathEnv, exists, exts, sep) {
|
|
34
|
+
for (const dir of String(pathEnv || '').split(sep)) {
|
|
35
|
+
if (!dir) continue;
|
|
36
|
+
for (const ext of exts) {
|
|
37
|
+
const p = join(dir, name + ext);
|
|
38
|
+
if (exists(p)) return p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is the Claude Code CLI spawnable on this host? Never executes anything — a
|
|
46
|
+
* handful of stat()s, like resolveClaudeBin. On Windows the npm `.cmd` shim
|
|
47
|
+
* case is delegated to preflight's probe so the hint matches the run-time error.
|
|
48
|
+
* @param {string} [bin]
|
|
49
|
+
* @param {{platform?:string, pathEnv?:string, exists?:(p:string)=>boolean}} [opts]
|
|
50
|
+
* @returns {{ready:boolean, bin:string, hint:string|null}}
|
|
51
|
+
*/
|
|
52
|
+
export function claudeReady(bin = configuredClaudeBin(), opts = {}) {
|
|
53
|
+
const platform = opts.platform ?? process.platform;
|
|
54
|
+
const pathEnv = opts.pathEnv ?? (process.env.PATH ?? '');
|
|
55
|
+
const exists = opts.exists ?? existsSync;
|
|
56
|
+
const name = String(bin || 'claude').trim() || 'claude';
|
|
57
|
+
if (platform === 'win32') {
|
|
58
|
+
const hint = explainUnspawnableClaude(name, { platform, pathEnv, exists });
|
|
59
|
+
if (hint) return { ready: false, bin: name, hint };
|
|
60
|
+
const r = resolveClaudeBin(name, { platform, pathEnv, exists });
|
|
61
|
+
if (r.source !== 'as-is') return { ready: true, bin: r.bin, hint: null };
|
|
62
|
+
if (hasSep(name) || isAbsolute(name)) return { ready: exists(name), bin: name, hint: null };
|
|
63
|
+
const hit = findOnPath(name, pathEnv, exists, ['.exe', ''], ';');
|
|
64
|
+
return { ready: !!hit, bin: hit || name, hint: null };
|
|
65
|
+
}
|
|
66
|
+
if (hasSep(name)) return { ready: exists(name), bin: name, hint: null };
|
|
67
|
+
const hit = findOnPath(name, pathEnv, exists, [''], ':');
|
|
68
|
+
return { ready: !!hit, bin: hit || name, hint: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The eight ticks plus the two stored flags, in one call.
|
|
73
|
+
* @returns {Promise<{steps:Record<string,boolean>, done:number, total:number,
|
|
74
|
+
* claude:{bin:string, hint:string|null}, hidden:boolean, welcomeSeen:boolean}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function onboardingStatus() {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const count = (sql) => { const row = db.prepare(sql).get(); return row ? Number(row.n) : 0; };
|
|
79
|
+
const claude = claudeReady();
|
|
80
|
+
let teamMetrics = false;
|
|
81
|
+
// Cached status only (no discovery): this is read at boot and after every change
|
|
82
|
+
// broadcast, and the Team metrics page owns the expensive refresh.
|
|
83
|
+
try { teamMetrics = !!(await listScopes()).anyEnabled; } catch { /* offline / no git: not enabled */ }
|
|
84
|
+
const steps = {
|
|
85
|
+
claude: claude.ready,
|
|
86
|
+
project: countProjects() > 0,
|
|
87
|
+
run: count("SELECT COUNT(*) AS n FROM pipelines WHERE status = 'done'") > 0,
|
|
88
|
+
realRun: count('SELECT COUNT(*) AS n FROM pipelines WHERE total_cost_usd > 0') > 0,
|
|
89
|
+
ask: countThreads() > 0,
|
|
90
|
+
// The New pipeline picker persists a choice per project (PATCH /api/config
|
|
91
|
+
// activeWorkflowId) the moment one is picked — Auto included: knowing the
|
|
92
|
+
// picker exists is the step, not which workflow won.
|
|
93
|
+
workflows: count("SELECT COUNT(*) AS n FROM project_config WHERE active_workflow_id IS NOT NULL AND TRIM(active_workflow_id) != ''") > 0,
|
|
94
|
+
workspace: countWorkspaces() > 0,
|
|
95
|
+
teamMetrics,
|
|
96
|
+
};
|
|
97
|
+
const done = ONBOARDING_STEPS.filter((id) => steps[id]).length;
|
|
98
|
+
return { steps, done, total: ONBOARDING_STEPS.length, claude: { bin: claude.bin, hint: claude.hint }, ...onboardingPrefs() };
|
|
99
|
+
}
|
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
// an ordinary execution, `x:<nodeId>:<ordinal>:<taskId>` for a composite slice.
|
|
11
11
|
// state.steps[] IS the execution ledger: one row per execution, key ===
|
|
12
12
|
// executionId. There is no separate executions[] array.
|
|
13
|
-
import { join, isAbsolute } from 'node:path';
|
|
14
|
-
import { rm } from 'node:fs/promises';
|
|
13
|
+
import { join, isAbsolute, extname } from 'node:path';
|
|
14
|
+
import { rm, readFile } from 'node:fs/promises';
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
RunHarness, isAbort, isPause, pauseErr, firstLine, jsonClone,
|
|
18
18
|
clipMiddle, sumStepActive, normalizeClarifyAnswer,
|
|
19
19
|
} from './run-harness.mjs';
|
|
20
|
-
import { resolveGraph, loadAgentFile, GRAPH_DEFAULT_WORKFLOW } from './workflows.mjs';
|
|
20
|
+
import { resolveGraph, loadAgentFile, GRAPH_DEFAULT_WORKFLOW, writeGraphWorkflow, readWorkflow } from './workflows.mjs';
|
|
21
|
+
import { AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME } from './graph/builtin-workflows.mjs';
|
|
21
22
|
import { classifyLoops } from '../shared/graph/loops.mjs';
|
|
22
23
|
import { buildGraphManifest, manifestTemplate, manifestPortsFn } from '../shared/graph/manifest.mjs';
|
|
23
24
|
import { DEFAULT_MAX_CYCLES } from '../shared/graph/constants.mjs';
|
|
@@ -25,7 +26,13 @@ import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
|
25
26
|
import { createScheduler, sliceExecutionId, QUIESCENCE_WARNING } from './graph/scheduler.mjs';
|
|
26
27
|
import { runExecution, allocateOutputs, allocateVerdict, readDecomposition } from './graph/executor.mjs';
|
|
27
28
|
import { renderPromptArtifact } from './phases.mjs';
|
|
28
|
-
import { modelHasBaseUrlRouting } from './config.mjs';
|
|
29
|
+
import { listModels, modelHasBaseUrlRouting, resolveRunConfig } from './config.mjs';
|
|
30
|
+
import { assembleShape, ShapeError } from '../shared/graph/assemble.mjs';
|
|
31
|
+
import { fingerprintProject } from './auto/fingerprint.mjs';
|
|
32
|
+
import { classifyTask, ClassifierError } from './auto/classify.mjs';
|
|
33
|
+
import { autoCandidates, findEquivalentWorkflow } from './auto/match.mjs';
|
|
34
|
+
import { buildProposal, sanitizeProposalAnswer, remapTunables, mintAutoWorkflowId } from './auto/proposal.mjs';
|
|
35
|
+
import { resolveAutoModel } from './auto/model.mjs';
|
|
29
36
|
import {
|
|
30
37
|
appendAudit, writeReview, reviewKindOf, writeDecomposition, updateTaskStatus,
|
|
31
38
|
updatePhaseStatus, writeStepQuestions, readStepQuestions,
|
|
@@ -43,6 +50,12 @@ function abortError(msg = 'aborted') {
|
|
|
43
50
|
return e;
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/** Token usage summed over the classifier's attempts (a retry is billed to the same round). */
|
|
54
|
+
const sumUsage = (a, b) => ({
|
|
55
|
+
input_tokens: (Number(a?.input_tokens) || 0) + (Number(b?.input_tokens) || 0),
|
|
56
|
+
output_tokens: (Number(a?.output_tokens) || 0) + (Number(b?.output_tokens) || 0),
|
|
57
|
+
});
|
|
58
|
+
|
|
46
59
|
export function createOrchestrator(opts = {}) {
|
|
47
60
|
return new GraphOrchestrator(opts);
|
|
48
61
|
}
|
|
@@ -62,6 +75,13 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
62
75
|
this._planVersion = 0; // {vsuffix} ticks, carried across a resume
|
|
63
76
|
this._taskArtifact = null; // the pre-rendered task document
|
|
64
77
|
this.extrasFiles = [];
|
|
78
|
+
// Auto workflow (spec §5): the decision loop's state. `feedback`/`round`/`prior`
|
|
79
|
+
// ride the resume point while the run is undecided; `costUsd` is the running
|
|
80
|
+
// classifier spend shown in the proposal. `pending` = the proposal that is OPEN
|
|
81
|
+
// (or the round a cost cap parked), replayed on resume without a classifier call.
|
|
82
|
+
// `classify` is the test seam.
|
|
83
|
+
this._auto = { feedback: [], round: 0, prior: null, costUsd: 0, pending: null };
|
|
84
|
+
this._classify = typeof opts?.classify === 'function' ? opts.classify : null;
|
|
65
85
|
Object.assign(this.state, {
|
|
66
86
|
engine: 2,
|
|
67
87
|
active: [], // [{nodeId, executionId}]
|
|
@@ -91,6 +111,7 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
91
111
|
* @returns {Promise<{manifest:object, agentKeys:Set<string>, workflow:{id:string,name:string}}>}
|
|
92
112
|
*/
|
|
93
113
|
async _resolveTopology(registry) {
|
|
114
|
+
if (this.workflowId === AUTO_WORKFLOW_ID) return this._autoBootstrapTopology();
|
|
94
115
|
const resolved = await resolveGraph(this.projectDir, this.workflowId, registry, this.agentsDir, {
|
|
95
116
|
isWorkspace: this.isWorkspace,
|
|
96
117
|
});
|
|
@@ -108,6 +129,341 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
108
129
|
};
|
|
109
130
|
}
|
|
110
131
|
|
|
132
|
+
/** The Auto entry before the decision: an EMPTY graph tagged `deciding`, so
|
|
133
|
+
* the run row, the Running page and a pre-decision resume point all have a
|
|
134
|
+
* manifest to carry (spec §5.2). */
|
|
135
|
+
_autoBootstrapTopology() {
|
|
136
|
+
const manifest = buildGraphManifest({ id: AUTO_WORKFLOW_ID, name: AUTO_WORKFLOW_NAME, version: 2, domain: 'coding', nodes: [], wires: [] }, {});
|
|
137
|
+
manifest.auto = { status: 'deciding', humanInLoop: this.humanInLoop };
|
|
138
|
+
return { manifest, agentKeys: new Set(), workflow: { id: AUTO_WORKFLOW_ID, name: AUTO_WORKFLOW_NAME } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Auto workflow: the decision loop (spec §5.3) ──────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* Classify → assemble → match → propose → (accept | revise | cancel) → adopt.
|
|
144
|
+
* run() calls it (no argument) AFTER createPipeline + the run root (the hook site
|
|
145
|
+
* in run-harness.mjs); resume() calls it with `{ resume: rp }` BEFORE the setup
|
|
146
|
+
* replay for a run that paused undecided. Returns the topology bag that replaces
|
|
147
|
+
* the bootstrap manifest, or null when there is nothing to decide.
|
|
148
|
+
*/
|
|
149
|
+
async _decideTopology({ resume = null } = {}) {
|
|
150
|
+
if (this.workflowId !== AUTO_WORKFLOW_ID) return null;
|
|
151
|
+
if (resume) {
|
|
152
|
+
if (resume.manifest?.auto?.status !== 'deciding') return null; // decided before the pause: the normal resume path
|
|
153
|
+
const saved = resume.auto || {};
|
|
154
|
+
this._auto = {
|
|
155
|
+
...this._auto,
|
|
156
|
+
feedback: Array.isArray(saved.feedback) ? [...saved.feedback] : [],
|
|
157
|
+
round: Number(saved.round) || 0,
|
|
158
|
+
prior: saved.prior || null,
|
|
159
|
+
costUsd: Number.isFinite(Number(saved.costUsd)) ? Number(saved.costUsd) : 0, // B5: the spend before the pause
|
|
160
|
+
// B4/B6: the proposal that was OPEN (or the round a cost cap parked) — replayed below without a classifier call
|
|
161
|
+
pending: saved.pending && saved.pending.shape && typeof saved.pending.shape === 'object' ? jsonClone(saved.pending) : null,
|
|
162
|
+
};
|
|
163
|
+
// resume() stamps titleProvisional only AFTER this hook (run-harness.mjs:1420); a point
|
|
164
|
+
// re-stamped while the replayed proposal is open must not lose the flag.
|
|
165
|
+
if (resume.titleProvisional === true) this.state.titleProvisional = true;
|
|
166
|
+
this.humanInLoop = typeof saved.humanInLoop === 'boolean' ? saved.humanInLoop : (resume.manifest.auto.humanInLoop ?? this.humanInLoop);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return await this._decideTopologyInner();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// Whatever unwinds the decision (a cost cap, a classifier failure, the user's
|
|
172
|
+
// pause, a stop) leaves the CURRENT decision state on the row: both shells keep
|
|
173
|
+
// state.resumePoint when it is already set (run-harness.mjs :1150 / :1475 — a
|
|
174
|
+
// resume would otherwise re-arm the point it consumed, with a stale
|
|
175
|
+
// round/feedback), _pauseForFailure prefers it and restamps reason/detail, and
|
|
176
|
+
// the stop branch nulls it.
|
|
177
|
+
if (this.pipeline && this.workflowId === AUTO_WORKFLOW_ID) this.state.resumePoint = this._buildResumePoint(null);
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async _decideTopologyInner() {
|
|
183
|
+
const registry = this.registry;
|
|
184
|
+
const models = await listModels(this.projectDir);
|
|
185
|
+
const model = resolveAutoModel(models);
|
|
186
|
+
const fingerprint = await fingerprintProject(this.projectDir);
|
|
187
|
+
this._log('orchestrator', 'info', `auto: fingerprint ${Buffer.byteLength(fingerprint, 'utf8')} B`);
|
|
188
|
+
const extras = await this._autoExtras();
|
|
189
|
+
const taskText = this.pipeline?.promptText || this.opts.prompt || '';
|
|
190
|
+
const classify = this._classify || ((input) => classifyTask(input));
|
|
191
|
+
for (;;) {
|
|
192
|
+
this._checkAbort();
|
|
193
|
+
this._checkPause();
|
|
194
|
+
// B4/B6: a pending proposal (a pause with the question open, a cost cap inside the round, a
|
|
195
|
+
// server restart) is re-proposed AS-IS: the assembler and the matcher are deterministic and
|
|
196
|
+
// free, so the user sees the SAME proposal and pays no second classifier bill. Not a new round.
|
|
197
|
+
const pending = this._auto.pending;
|
|
198
|
+
let round;
|
|
199
|
+
let classifyFor;
|
|
200
|
+
if (pending) {
|
|
201
|
+
round = Number(pending.round) || this._auto.round || 1;
|
|
202
|
+
this._auto.round = round;
|
|
203
|
+
classifyFor = async (input) => ({
|
|
204
|
+
shape: jsonClone(pending.shape), warnings: Array.isArray(pending.warnings) ? [...pending.warnings] : [],
|
|
205
|
+
attempts: 0, costUsd: 0, usage: { input_tokens: 0, output_tokens: 0 }, raw: '', model: input.model || null, replayed: true,
|
|
206
|
+
});
|
|
207
|
+
this._log('orchestrator', 'info', `auto: re-proposing round ${round} from the saved point (no classifier call)`);
|
|
208
|
+
} else {
|
|
209
|
+
this._auto.round += 1;
|
|
210
|
+
round = this._auto.round;
|
|
211
|
+
classifyFor = classify;
|
|
212
|
+
}
|
|
213
|
+
let outcome;
|
|
214
|
+
try {
|
|
215
|
+
outcome = await this._autoRound({ registry, models, model, fingerprint, extras, taskText, classify: classifyFor, round });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (isAbort(err) || isPause(err)) throw err;
|
|
218
|
+
if (pending && err instanceof ShapeError) {
|
|
219
|
+
// The saved shape no longer assembles (the registry changed while the run was parked):
|
|
220
|
+
// drop it and classify afresh in THIS resume instead of parking the run a second time.
|
|
221
|
+
this._log('orchestrator', 'warn', `auto: the saved proposal no longer assembles (${firstLine(err.message)}); classifying afresh`);
|
|
222
|
+
this._auto.pending = null;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (err instanceof ClassifierError || err instanceof ShapeError) {
|
|
226
|
+
// spec D17 / §5.6: the shell's failure policy parks the run (setup site ⇒
|
|
227
|
+
// pause, reason 'error', detail = the message) and the resume point keeps
|
|
228
|
+
// auto.status 'deciding' + this loop's state, so resume() re-decides.
|
|
229
|
+
this._log('orchestrator', 'warn', `auto: classifier failed: ${firstLine(err.detail || err.message)}`);
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
const { proposal, template, match, tunables, shape } = outcome;
|
|
234
|
+
this._checkPause(); // a pause requested while the classifier was out parks the run BEFORE any row is written
|
|
235
|
+
if (this.humanInLoop) {
|
|
236
|
+
// B6: the proposal may stay open for hours. Stamp the current decision state on the row
|
|
237
|
+
// NOW, so a server restart in this window reconciles to a RESUMABLE row that resumes
|
|
238
|
+
// into this very proposal. _autoAdopt nulls the point once decided; every throw below
|
|
239
|
+
// it goes through _decideTopology's catch, which rebuilds the point (pending included).
|
|
240
|
+
await this._stampDecisionPoint();
|
|
241
|
+
}
|
|
242
|
+
const answer = this.humanInLoop
|
|
243
|
+
? await this._autoAsk(proposal, models, registry)
|
|
244
|
+
: { decision: 'accept', name: proposal.name, nodes: {} };
|
|
245
|
+
if (answer.decision === 'cancel') {
|
|
246
|
+
this._log('orchestrator', 'info', 'auto: cancelled by the user');
|
|
247
|
+
await appendAudit(this.pipeline.dir, 'Auto workflow **cancelled** by the user.').catch(() => {});
|
|
248
|
+
this.stop();
|
|
249
|
+
throw abortError('cancelled');
|
|
250
|
+
}
|
|
251
|
+
if (answer.decision === 'revise') {
|
|
252
|
+
this._auto.pending = null; // answered: the next round classifies afresh
|
|
253
|
+
this._auto.feedback.push(answer.text);
|
|
254
|
+
this._auto.prior = shape;
|
|
255
|
+
this._log('orchestrator', 'info', `auto: revise — ${clipMiddle(answer.text, 200)}`);
|
|
256
|
+
// PR #434 review, finding 2: until the NEXT round's own stamp the feedback lives only in
|
|
257
|
+
// memory, and that round opens with a 60–120 s classifier call. A hard kill in that
|
|
258
|
+
// window (ui/server.mjs shutdown() never pauses runs; the boot reconcile keeps the row's
|
|
259
|
+
// point) would resume into the stamp above and re-show the ORIGINAL proposal with the
|
|
260
|
+
// revise text gone. Persist the decision state now.
|
|
261
|
+
await this._stampDecisionPoint();
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
return await this._autoAdopt({ template, match, tunables, shape, answer, registry, round });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One round: classifier call (one assembler-driven retry), match, proposal. */
|
|
269
|
+
async _autoRound({ registry, models, model, fingerprint, extras, taskText, classify, round }) {
|
|
270
|
+
const input = {
|
|
271
|
+
taskText, extras, fingerprint, models, registry,
|
|
272
|
+
domain: 'coding', // the domain the assembler stamps: coding + shared + general agents are offered
|
|
273
|
+
humanInLoop: this.humanInLoop, feedback: [...this._auto.feedback], priorShape: this._auto.prior,
|
|
274
|
+
// D6 amendment (2026-09-07): the classifier may Grep/Glob/Read the RUN'S OWN checkout to
|
|
275
|
+
// size the change — this.runCwd is set by _setupRunRoot before the run() hook
|
|
276
|
+
// (run-harness.mjs:1641) and rehydrated before the resume() hook (:1360). It is never the
|
|
277
|
+
// user's live checkout. With no worktree (not a case a project run reaches today) it
|
|
278
|
+
// falls back to the scratch dir, text-only, exactly as before. (A detached WORKSPACE run's
|
|
279
|
+
// runCwd is the neutral run root whose repos/<key>/ checkouts sit below it — still readable.)
|
|
280
|
+
model, cwd: this.runCwd || this.pipeline.dir, repoLook: !!this.runCwd, bin: this.claude.bin, mock: this.claude.mock,
|
|
281
|
+
// Stop OR pause ends the call (the same composition every node spawn uses).
|
|
282
|
+
signal: AbortSignal.any([this.abort.signal, this.pauseAbort.signal]),
|
|
283
|
+
envScrub: this.guardrails?.envScrub || undefined,
|
|
284
|
+
envAllowlist: this.guardrails?.envScrub ? this.guardrails.envAllowlist : undefined,
|
|
285
|
+
};
|
|
286
|
+
const startedAt = new Date().toISOString();
|
|
287
|
+
let classified = null;
|
|
288
|
+
let assembled;
|
|
289
|
+
try {
|
|
290
|
+
classified = await classify(input);
|
|
291
|
+
try {
|
|
292
|
+
assembled = assembleShape(classified.shape, { registry, humanInLoop: this.humanInLoop });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// A REPLAYED shape (B4/B6 resume) gets no second round here: `classify` is then the replay
|
|
295
|
+
// stub, which would only hand the same stale shape back. Let the ShapeError escape to
|
|
296
|
+
// _decideTopologyInner, which drops the pending proposal and classifies afresh.
|
|
297
|
+
if (!(err instanceof ShapeError) || classified.replayed) throw err;
|
|
298
|
+
// ONE more classifier round with the assembler's issues as feedback (spec §5.3);
|
|
299
|
+
// a second ShapeError propagates and pauses the run.
|
|
300
|
+
const note = `The previous shape could not be assembled: ${err.issues.map((i) => i.message).join('; ')}. Fix it and reply with the full shape.`;
|
|
301
|
+
const again = await classify({ ...input, feedback: [...input.feedback, note], priorShape: classified.shape });
|
|
302
|
+
again.costUsd = (Number(again.costUsd) || 0) + (Number(classified.costUsd) || 0);
|
|
303
|
+
again.usage = sumUsage(classified.usage, again.usage);
|
|
304
|
+
classified = again;
|
|
305
|
+
assembled = assembleShape(classified.shape, { registry, humanInLoop: this.humanInLoop });
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// A FAILED round still spent money (two billed replies behind CLASSIFIER_FAILED, a
|
|
309
|
+
// partial reply behind a timeout, the first shape behind a failed assembler retry):
|
|
310
|
+
// book it before the shell parks the run, or the caps never see it (D14). No cap
|
|
311
|
+
// check here — the error pause is happening anyway; the next round checks.
|
|
312
|
+
const spent = (Number(classified?.costUsd) || 0) + (Number(err?.costUsd) || 0);
|
|
313
|
+
if (spent > 0 || err?.usage) this._recordAutoCost(round, { costUsd: spent, usage: sumUsage(classified?.usage, err?.usage) }, startedAt, model, { checkCaps: false });
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
// B4: keep this round's shape (and the classifier's warnings) from here on — a cost cap raised
|
|
317
|
+
// by _recordAutoCost below, or a pause while the proposal is open, resumes into it instead of
|
|
318
|
+
// paying for a new classification. Set BEFORE the cost row: the cap check lives inside it.
|
|
319
|
+
this._auto.pending = { round, shape: jsonClone(assembled.shape), warnings: [...(classified.warnings || [])] };
|
|
320
|
+
if (!classified.replayed) this._recordAutoCost(round, classified, startedAt, model);
|
|
321
|
+
const match = findEquivalentWorkflow(assembled.template, await autoCandidates());
|
|
322
|
+
let template = assembled.template;
|
|
323
|
+
let tunables = assembled.tunables;
|
|
324
|
+
let ignoredProjectOverrides = false;
|
|
325
|
+
if (match) {
|
|
326
|
+
tunables = remapTunables(tunables, match.nodeMap);
|
|
327
|
+
template = match.candidate;
|
|
328
|
+
// D7: Auto owns the tuning — a reused row's per-project node/wire overrides are
|
|
329
|
+
// NOT applied; the proposal says so, so the user is not surprised.
|
|
330
|
+
const rc = await resolveRunConfig(this.projectDir, match.candidate.id);
|
|
331
|
+
ignoredProjectOverrides = Object.keys(rc?.nodes || {}).length > 0 || Object.keys(rc?.wires || {}).length > 0;
|
|
332
|
+
// With human-in-the-loop OFF there is no proposal to say it (PR #434 review, finding 5):
|
|
333
|
+
// the run log is then the only place the user can learn why the run used models they
|
|
334
|
+
// never picked, so say it here regardless of the switch.
|
|
335
|
+
if (ignoredProjectOverrides) {
|
|
336
|
+
this._log('orchestrator', 'warn', `auto: this project's saved per-node/wire settings for "${match.candidate.name}" (${match.candidate.id}) are not applied — Auto owns the tuning`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const proposal = buildProposal({
|
|
340
|
+
round, shape: assembled.shape, template,
|
|
341
|
+
match: match ? { id: match.candidate.id, name: match.candidate.name } : null,
|
|
342
|
+
tunables, registry, models,
|
|
343
|
+
warnings: [...(classified.warnings || []), ...assembled.warnings],
|
|
344
|
+
costUsd: this._auto.costUsd, fingerprint, ignoredProjectOverrides,
|
|
345
|
+
});
|
|
346
|
+
this._log('orchestrator', 'info',
|
|
347
|
+
`auto: round ${round} proposed "${proposal.name}" (${Object.keys(proposal.nodes).length} agents) — ${match ? `same shape as saved workflow "${match.candidate.name}" (${match.candidate.id})` : 'no saved workflow has this shape; Accept saves a new one'}`);
|
|
348
|
+
return { proposal, template, match, tunables, shape: assembled.shape };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Ask the proposal ONCE; the validator keeps the question OPEN on a malformed
|
|
352
|
+
* answer (spec §5.4) and the awaiting code receives the sanitised payload. */
|
|
353
|
+
async _autoAsk(proposal, models, registry) {
|
|
354
|
+
const validate = (raw) => sanitizeProposalAnswer(raw, { proposal, models, registry });
|
|
355
|
+
const raw = await this._ask({ id: `auto-${proposal.round}`, kind: 'workflow', workflow: proposal, validate });
|
|
356
|
+
return raw && raw.decision ? raw : validate(raw); // auto mode answers { decision: 'accept' } without the validator
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Reuse the twin or save a new row, resolve it with the accepted tunables as the ONLY overlay, re-stamp the manifest. */
|
|
360
|
+
async _autoAdopt({ template, match, tunables, shape, answer, registry, round }) {
|
|
361
|
+
const name = answer.name || shape.name;
|
|
362
|
+
if (!match) {
|
|
363
|
+
// B3: the twin search ran at proposal time; another Auto run, the composer or the chat may
|
|
364
|
+
// have saved this exact topology while the proposal was open. Reuse it now rather than
|
|
365
|
+
// write a duplicate — remapping the classifier's tunables AND the user's table edits
|
|
366
|
+
// (both keyed by the assembled node ids) onto the twin's node ids.
|
|
367
|
+
const late = findEquivalentWorkflow(template, await autoCandidates());
|
|
368
|
+
if (late) {
|
|
369
|
+
this._log('orchestrator', 'info', `auto: saved workflow "${late.candidate.name}" (${late.candidate.id}) appeared while the proposal was open — reusing it`);
|
|
370
|
+
match = late;
|
|
371
|
+
tunables = remapTunables(tunables, late.nodeMap);
|
|
372
|
+
answer = { ...answer, nodes: remapTunables(answer.nodes || {}, late.nodeMap) };
|
|
373
|
+
template = late.candidate;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
let workflowId;
|
|
377
|
+
let via;
|
|
378
|
+
if (match) {
|
|
379
|
+
workflowId = match.candidate.id;
|
|
380
|
+
via = 'reused';
|
|
381
|
+
} else {
|
|
382
|
+
workflowId = await mintAutoWorkflowId(name, async (id) => !!(await readWorkflow(id, { includeArchived: true })));
|
|
383
|
+
await writeGraphWorkflow({ ...template, id: workflowId, name, domain: 'coding', origin: 'auto' });
|
|
384
|
+
via = 'created';
|
|
385
|
+
}
|
|
386
|
+
const overlayNodes = {};
|
|
387
|
+
for (const [nodeId, sel] of Object.entries(tunables || {})) overlayNodes[nodeId] = { ...sel };
|
|
388
|
+
for (const [nodeId, sel] of Object.entries(answer.nodes || {})) overlayNodes[nodeId] = { ...(overlayNodes[nodeId] || {}), ...sel };
|
|
389
|
+
const resolved = await resolveGraph(this.projectDir, workflowId, registry, this.agentsDir, {
|
|
390
|
+
isWorkspace: false, overlay: { nodes: overlayNodes }, ignoreProjectOverrides: true,
|
|
391
|
+
});
|
|
392
|
+
if (!this.humanInLoop) {
|
|
393
|
+
// spec D3: no agent may stop the run to ask (generic — every agent node).
|
|
394
|
+
for (const nc of Object.values(resolved.nodes)) if (nc.kind === 'agent') nc.askQuestions = false;
|
|
395
|
+
}
|
|
396
|
+
this.workflowId = workflowId;
|
|
397
|
+
this._adoptResolvedGraph(resolved);
|
|
398
|
+
const manifest = buildGraphManifest(this.resolved.template, this.resolved.agentsByKey, {
|
|
399
|
+
overlays: { nodes: this.resolved.nodeCtx, wires: this.resolved.wires },
|
|
400
|
+
});
|
|
401
|
+
manifest.auto = { status: 'decided', via, rounds: round, humanInLoop: this.humanInLoop, workflowId };
|
|
402
|
+
this._preflightAgentKeys(this.resolved.agentKeys);
|
|
403
|
+
this.state.stepper = manifest;
|
|
404
|
+
// PR #434 review, finding 3: the pending proposal is kept until HERE. A throw before the
|
|
405
|
+
// workflowId swap above (mintAutoWorkflowId, writeGraphWorkflow, resolveGraph) unwinds
|
|
406
|
+
// through _decideTopology's catch, which rebuilds the point WITH it, so the resume replays
|
|
407
|
+
// the same proposal for free instead of paying a second classifier round. (The answer
|
|
408
|
+
// itself is not persisted: with a proposal open the user answers the replay again. That
|
|
409
|
+
// catch is gated on workflowId === AUTO_WORKFLOW_ID: a throw after the swap leaves the
|
|
410
|
+
// point as it was — the B6 stamp when a proposal was open, none otherwise — and the steps
|
|
411
|
+
// between are bookkeeping over the graph resolveGraph just resolved.)
|
|
412
|
+
this._auto.pending = null;
|
|
413
|
+
this.state.resumePoint = null; // decided: the engine's onSnapshot owns the point from here
|
|
414
|
+
this._emit('state', this.getState());
|
|
415
|
+
await this._persist();
|
|
416
|
+
this._log('orchestrator', 'info', `auto: accepted → "${name}" (${workflowId}, ${via})`);
|
|
417
|
+
await appendAudit(this.pipeline.dir, `Auto workflow: **${name}** — ${via === 'reused' ? `reusing saved workflow ${workflowId}` : `saved as ${workflowId}`}.`).catch(() => {});
|
|
418
|
+
return { manifest, agentKeys: new Set(this.resolved.agentKeys), workflow: { id: workflowId, name: this.resolved.template.name || name } };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Attached files as the classifier sees them: names, plus the first 2 KB of text files. */
|
|
422
|
+
async _autoExtras() {
|
|
423
|
+
const out = [];
|
|
424
|
+
for (const f of await this._collectExtras()) {
|
|
425
|
+
const ext = extname(f.name).toLowerCase();
|
|
426
|
+
let text;
|
|
427
|
+
if (['.md', '.txt', '.json', '.yaml', '.yml', '.csv', '.toml'].includes(ext)) {
|
|
428
|
+
text = await readFile(f.path, 'utf8').then((t) => t.slice(0, 2048)).catch(() => undefined);
|
|
429
|
+
}
|
|
430
|
+
out.push(text !== undefined ? { name: f.name, text } : { name: f.name });
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Cost of one classifier round: a sub-agent row (state list + table + delta) + the preflight ledger + the caps (spec §5.7).
|
|
436
|
+
* `checkCaps: false` books the spend of a round that FAILED without raising a cost pause on top of the error pause. */
|
|
437
|
+
_recordAutoCost(round, classified, startedAt, model, { checkCaps = true } = {}) {
|
|
438
|
+
const costUsd = Number.isFinite(Number(classified?.costUsd)) ? Number(classified.costUsd) : 0;
|
|
439
|
+
const usage = classified?.usage || {};
|
|
440
|
+
this._auto.costUsd = Math.round((this._auto.costUsd + costUsd) * 1e6) / 1e6;
|
|
441
|
+
const rec = {
|
|
442
|
+
id: `auto-classify-${round}`, label: `Auto workflow (round ${round})`, status: 'finished',
|
|
443
|
+
startedAt, finishedAt: new Date().toISOString(), costUsd,
|
|
444
|
+
tokens: (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0),
|
|
445
|
+
subagentType: 'auto-classify', uiPhase: 'preflight', nodeId: 'preflight', stepKey: 'x:preflight:1',
|
|
446
|
+
runModel: model || null,
|
|
447
|
+
};
|
|
448
|
+
// The pattern every sub-agent record follows (run-harness.mjs:3392-3394): the state
|
|
449
|
+
// list + the table + a delta, so the Running view and the CLI pill see the row
|
|
450
|
+
// without a reload; History reads the table. _subAgentTransition is a pure
|
|
451
|
+
// emitter (its first argument is the transition: 'spawn' | 'finish' | 'update');
|
|
452
|
+
// the row is born finished, so both deltas go out back to back and any consumer
|
|
453
|
+
// that balances spawns against finishes stays balanced.
|
|
454
|
+
if (!this.state.subAgents.some((s) => s.id === rec.id)) this.state.subAgents.push(rec);
|
|
455
|
+
this._upsertSubAgent(rec);
|
|
456
|
+
this._subAgentTransition('spawn', rec);
|
|
457
|
+
this._subAgentTransition('finish', rec);
|
|
458
|
+
// The preflight ledger row exists because _bookend('preflight','start') ran in
|
|
459
|
+
// run() (and resume() rehydrates state.steps before the hook). _recordCost only
|
|
460
|
+
// attributes a cost whose stepKey names a ledger row (state.steps + totalCostUsd —
|
|
461
|
+
// no else branch; the DB spend ledger is written regardless), and
|
|
462
|
+
// _checkCostLimits reads that total: without the row the pipeline cap could never trip.
|
|
463
|
+
this._recordCost(costUsd, 'x:preflight:1');
|
|
464
|
+
if (checkCaps) this._checkCostLimits(); // a cost cap pauses here (_capReached → pauseErr()); the resume re-enters the decision
|
|
465
|
+
}
|
|
466
|
+
|
|
111
467
|
/**
|
|
112
468
|
* Adopt a resolveGraph result (P2 contract: { template, ports, loops, nodes,
|
|
113
469
|
* wires, agentsByKey, agentKeys }). The resolver has ALREADY applied the
|
|
@@ -175,6 +531,10 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
175
531
|
* @returns {Promise<'done'|'paused'>}
|
|
176
532
|
*/
|
|
177
533
|
async _engineRun({ resume = null } = {}) {
|
|
534
|
+
// An Auto run that paused BEFORE deciding was re-decided by resume() (before the
|
|
535
|
+
// setup replay, so the skills gate saw the adopted agents); its point holds the
|
|
536
|
+
// bootstrap manifest and no snapshot, so it starts from scratch like a fresh run.
|
|
537
|
+
if (resume?.manifest?.auto?.status === 'deciding') resume = null;
|
|
178
538
|
if (resume) await this._restoreFromResumePoint(resume); // Task 6 (hook-4 companion)
|
|
179
539
|
const { ports, loops } = this.resolved;
|
|
180
540
|
this.extrasFiles = await this._collectExtras();
|
|
@@ -251,6 +611,18 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
251
611
|
return 'done';
|
|
252
612
|
}
|
|
253
613
|
|
|
614
|
+
/** Stamp the CURRENT decision state on the row as the setup-incomplete point every Auto
|
|
615
|
+
* pause produces (run-harness.mjs _completePaused): a hard kill after this persist
|
|
616
|
+
* reconciles to a RESUMABLE row that resumes into exactly this state. Used while a
|
|
617
|
+
* proposal is open (B6) and right after a revise answer (PR #434 review, finding 2). */
|
|
618
|
+
async _stampDecisionPoint() {
|
|
619
|
+
const rp = this._buildResumePoint(null);
|
|
620
|
+
rp.setupIncomplete = true;
|
|
621
|
+
rp.titleProvisional = this.state.titleProvisional === true;
|
|
622
|
+
this.state.resumePoint = rp;
|
|
623
|
+
await this._persist();
|
|
624
|
+
}
|
|
625
|
+
|
|
254
626
|
/**
|
|
255
627
|
* Serialize the run position into a JSON-safe resume-v2 point. The scheduler
|
|
256
628
|
* snapshot IS the position; the manifest freezes the topology (resume never
|
|
@@ -278,7 +650,18 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
278
650
|
planVersion: this._planVersion,
|
|
279
651
|
stepModels: this.stepModels,
|
|
280
652
|
workflowId: this.workflowId,
|
|
653
|
+
// Auto workflow: the decision state while UNDECIDED (spec §5.6); null once
|
|
654
|
+
// the graph is adopted (workflowId is then the real id) and on saved workflows.
|
|
655
|
+
auto: this.workflowId === AUTO_WORKFLOW_ID
|
|
656
|
+
? {
|
|
657
|
+
humanInLoop: this.humanInLoop, feedback: [...this._auto.feedback], round: this._auto.round,
|
|
658
|
+
prior: this._auto.prior ? jsonClone(this._auto.prior) : null,
|
|
659
|
+
costUsd: this._auto.costUsd, // B5
|
|
660
|
+
pending: this._auto.pending ? jsonClone(this._auto.pending) : null, // B4/B6
|
|
661
|
+
}
|
|
662
|
+
: null,
|
|
281
663
|
guardrailsId: this.guardrailsId,
|
|
664
|
+
memoryScope: this.memoryScope || null, // agent memory §7.3: a paused defrag resumes with ONE scope (B10)
|
|
282
665
|
checkpointRef: this.checkpointRef || null,
|
|
283
666
|
checkpointRefs: { ...this.checkpointRefs },
|
|
284
667
|
workspace: this.isWorkspace ? { projects: this._workspaceProjects() } : null,
|
|
@@ -368,7 +751,7 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
368
751
|
// in P6 serves exactly what listArtifacts() carries).
|
|
369
752
|
if (payload.result?.path) {
|
|
370
753
|
this._artifact('result', payload.result.path, {
|
|
371
|
-
nodeId: payload.nodeId, executionId: payload.executionId, port: null,
|
|
754
|
+
nodeId: payload.nodeId, executionId: payload.executionId, port: null, cycle: null,
|
|
372
755
|
});
|
|
373
756
|
}
|
|
374
757
|
}
|
|
@@ -624,6 +1007,8 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
624
1007
|
pipelineId: this.pipeline.id,
|
|
625
1008
|
taskPrompt: this.pipeline.promptText,
|
|
626
1009
|
toolInstruction: this.toolInstruction,
|
|
1010
|
+
memoryBlock: this.memoryBlock || '', // §4.3: the pointer block, rendered once per mount
|
|
1011
|
+
memoryMount: this.memory?.mount || null, // absolute mount dir: <runCwd>/.claude/rules/worca (tests + the defrag mock read it)
|
|
627
1012
|
agentPrompts: this.agentPrompts,
|
|
628
1013
|
checkpointRef: this.checkpointRef,
|
|
629
1014
|
workspace: this.isWorkspace ? this._workspaceChannel() : undefined,
|
|
@@ -853,9 +1238,11 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
853
1238
|
if (!path || seen.has(path)) continue;
|
|
854
1239
|
seen.add(path);
|
|
855
1240
|
this._artifact(port.artifactKind || port.id, path, {
|
|
856
|
-
nodeId: ctx.nodeId, executionId: ctx.executionId, port: port.id,
|
|
1241
|
+
nodeId: ctx.nodeId, executionId: ctx.executionId, port: port.id, cycle: ctx.ordinal,
|
|
857
1242
|
});
|
|
858
1243
|
}
|
|
1244
|
+
// Agent memory (§5): sync the mount back after EVERY execution, slices included.
|
|
1245
|
+
await this._syncMemory(nc, ctx);
|
|
859
1246
|
if (nc.meta?.sideEffect === 'code' && !ctx.slice) await this._stageWorkingTree();
|
|
860
1247
|
}
|
|
861
1248
|
|
|
@@ -931,7 +1318,7 @@ export class GraphOrchestrator extends RunHarness {
|
|
|
931
1318
|
await writeStepQuestions(this.pipeline.id, stepKey, round, {
|
|
932
1319
|
agentKey: nc.key, nodeId: ctx.nodeId, questions: { questions },
|
|
933
1320
|
});
|
|
934
|
-
this._artifact('questions', qPath, { nodeId: ctx.nodeId, executionId: ctx.executionId, port: null });
|
|
1321
|
+
this._artifact('questions', qPath, { nodeId: ctx.nodeId, executionId: ctx.executionId, port: null, cycle: ctx.ordinal });
|
|
935
1322
|
await appendAudit(this.pipeline.dir, `${agentLabel} asked ${questions.length} question(s) (round ${round}).`).catch(() => {});
|
|
936
1323
|
const payload = await this._enqueueAsk(() => this._ask({
|
|
937
1324
|
id: `questions-${stepKey}-r${round}`,
|
package/src/core/phases.mjs
CHANGED
|
@@ -26,6 +26,9 @@ import { join } from 'node:path';
|
|
|
26
26
|
export const READ_WRITE_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob', 'Skill'];
|
|
27
27
|
// Implementer additionally gets MultiEdit for larger, multi-hunk edits.
|
|
28
28
|
export const IMPLEMENTER_TOOLS = ['Read', 'Write', 'Edit', 'MultiEdit', 'Bash', 'Grep', 'Glob', 'Skill'];
|
|
29
|
+
// A memory agent (sideEffect 'memory', agent-memory-design.md §7.1) edits files under the
|
|
30
|
+
// run's memory mount and nothing else: no Bash, no Skill, no MultiEdit.
|
|
31
|
+
export const MEMORY_TOOLS = ['Read', 'Write', 'Edit', 'Glob', 'Grep'];
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Effective `--allowedTools` for a node: the role's baseline file/exec tools UNION
|
|
@@ -371,14 +374,20 @@ export function workspaceFanOutDirective(strategy, ws, { relative = false, endpo
|
|
|
371
374
|
* sensible inline fallback when the body is missing/empty). The optional 4th
|
|
372
375
|
* `workspace` arg is the read-only workspace metadata; absent it,
|
|
373
376
|
* workspaceContextBlock returns '' and the prompt is byte-identical to today's
|
|
374
|
-
* single-project prompt.
|
|
377
|
+
* single-project prompt. The optional 5th `memoryBlock` arg is the rendered
|
|
378
|
+
* `## Worca memory` pointer block ('' when the run has no mount). Exported for testing.
|
|
375
379
|
*/
|
|
376
|
-
export function buildSystemPrompt(toolInstruction, agentBody, role, workspace) {
|
|
380
|
+
export function buildSystemPrompt(toolInstruction, agentBody, role, workspace, memoryBlock = '') {
|
|
377
381
|
const parts = [];
|
|
378
382
|
const tool = (toolInstruction || '').trim();
|
|
379
383
|
if (tool) parts.push(tool);
|
|
380
384
|
const ws = workspaceContextBlock(workspace); // '' when not a workspace run
|
|
381
385
|
if (ws) parts.push(ws);
|
|
386
|
+
// Agent memory (§4.3): the pointer block — the files themselves load natively from the
|
|
387
|
+
// cwd's .claude/rules/worca. Between the workspace preamble and the role body so the body
|
|
388
|
+
// (the contract) stays last. Trimmed: the renderer ends with one newline.
|
|
389
|
+
const mem = (typeof memoryBlock === 'string' ? memoryBlock : '').trim();
|
|
390
|
+
if (mem) parts.push(mem);
|
|
382
391
|
const body = (agentBody || '').trim();
|
|
383
392
|
// The agent's .md body IS the contract (spec §1: the engine is generic). The v1
|
|
384
393
|
// per-role FALLBACK_PROMPTS table died with the v1 engine; a missing body now
|
|
@@ -516,6 +525,10 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
|
|
|
516
525
|
// touch this env: its only wire is the prompt block (subagentModelDirective),
|
|
517
526
|
// and CLAUDE_CODE_SUBAGENT_MODEL is a reserved model-env key.
|
|
518
527
|
modelEnv: resolveModelEnv(c.model),
|
|
528
|
+
// Agent memory (§4.3): Task-tool sub-agents inherit the rules natively but not
|
|
529
|
+
// --append-system-prompt, so the pointer block rides the sub-agent flag. undefined when the
|
|
530
|
+
// run has no mount ⇒ buildClaudeArgs emits nothing and legacy argv stays byte-identical.
|
|
531
|
+
appendSubagentSystemPrompt: typeof ctx.memoryBlock === 'string' && ctx.memoryBlock.trim() ? ctx.memoryBlock : undefined,
|
|
519
532
|
// Guardrails: worca policy + lifted repo deny rules as {deny,...} rules ->
|
|
520
533
|
// ONE --settings payload; envScrub/envAllowlist -> spawn env. All undefined
|
|
521
534
|
// when the project has no guardrails, so the argv and env stay byte-identical
|
|
@@ -791,7 +804,7 @@ export async function runWorkspaceScan(ctx, opts = {}) {
|
|
|
791
804
|
const outPath = opts.outPath || joinPipeline(ctx.pipelineDir, 'workspace-description.md');
|
|
792
805
|
// The scanner IS the source of the workspace description, so it does NOT receive
|
|
793
806
|
// an injected workspace block (4th arg undefined). The body is the contract (C10).
|
|
794
|
-
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'workspaceScanner'), role, undefined);
|
|
807
|
+
const systemPrompt = buildSystemPrompt(ctx.toolInstruction, resolveAgentBody(ctx, 'workspaceScanner'), role, undefined, ctx.memoryBlock);
|
|
795
808
|
|
|
796
809
|
const memberLines = projects.map((p) =>
|
|
797
810
|
`- **${p.projectName || p.projectKey}** (\`${p.projectKey}\`): investigate \`${p.scanDir || p.projectDir}\`` +
|