@chatpanel/events 0.92.1 → 0.95.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/agent.js CHANGED
@@ -178,6 +178,13 @@ export function resolveTeam(team, pool = [], { chatModel = null, targetFor = nul
178
178
  // that is the bridge's id for Claude Code and the pilot's choice — change it on the card.
179
179
 
180
180
  export const STARTER_AGENTS = Object.freeze([
181
+ // The EXECUTIVE holds a goal (project-run.js): it posts the jobs, recruits for each from
182
+ // the pool, runs the recruited as a team, reads what came back, posts the follow-ups, asks
183
+ // the stakeholder where the gate says a person decides, and closes when done-when holds.
184
+ // A person is the stakeholder by default; this is the manager they delegate the running to.
185
+ { id: 'executive', name: 'Executive', purpose: 'Runs a project: posts the jobs for the goal, recruits from the pool, reads the results, posts follow-ups, asks before spending or changing scope, closes when done-when holds.',
186
+ prompt: 'You are the Executive. You hold one goal and its done-when. Break the goal into jobs a stranger could act on, each naming the skills and tools it needs; prefer jobs that run at the same time and use dependsOn only when one truly needs another\'s result. Read every result against done-when as written — say it holds only when it does on the results as they are, not as they could be. Post follow-up jobs only for what would move done-when. Say plainly what was not found. You never do a job yourself and never create an agent, tool or skill without a person\'s approval.',
187
+ skills: ['planning', 'management'], grants: ['none'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
181
188
  { id: 'architect', name: 'Architect', purpose: 'Reads the docs and the repos; writes the project page and the jobs.',
182
189
  prompt: 'You are the Architect. Read the feature doc, ROADMAP.md, naming-revamp.md and architecture-pillars.md before deciding anything. Write the project page (goal, done-when, budget) and post one job per repo that must change, saying which repo and what the guard is. Propose a new agent type only when no one in the pool fits. Never run a shell.',
183
190
  skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
package/client-prefs.js CHANGED
@@ -33,7 +33,7 @@ export const PREF_SECTIONS = Object.freeze([
33
33
  // fold on the gateway's project record, not here.
34
34
  { id: 'projects', label: 'Projects', path: ['projects'], kind: 'array' },
35
35
  { id: 'webSearch', label: 'Web search', path: ['ui', 'webSearch'], kind: 'object' },
36
- { id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
36
+ { id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'maxToolRoundsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
37
37
  { id: 'suggestions', label: 'Smart suggestions', path: ['ui', 'suggestions'], kind: 'object' },
38
38
  { id: 'topics', label: 'Topic extraction', path: ['ui', 'topicExtraction'], kind: 'object' },
39
39
  { id: 'voice', label: 'Voice', path: ['ui', 'voice'], kind: 'object' },
package/index.js CHANGED
@@ -170,8 +170,19 @@ export {
170
170
  export { explainMcpError, packageFromArgs, isStaleMcpSession } from './mcp-errors.js';
171
171
  // The tool round — what a tool does, how a round runs, what a result costs, how a tool is
172
172
  // found, and a workflow written down once (see docs/ROADMAP "the tool round" in chatpanel).
173
- export { toolTraits, bareToolName, canRunConcurrently, isCacheable, needsConfirmation, traitsIndex } from './tool-traits.js';
173
+ export { toolTraits, bareToolName, canRunConcurrently, isCacheable, needsConfirmation, traitsIndex, effectiveToolName, parallelEligible, PARALLEL_LOCAL_RE } from './tool-traits.js';
174
174
  export { planToolRound, runToolRound } from './tool-round.js';
175
+ // The turn loop — the one loop every client runs (rounds, guard, cap, exhaustion, usage),
176
+ // with the provider call, the tools and the transcript shape injected.
177
+ export {
178
+ createToolLoopGuard, roundSignature, stableToolCallKey, toolMadeProgress, isLoopableTool, blockedToolResult,
179
+ OBSERVATION_TOOLS, INPUT_PROGRESS_TOOLS,
180
+ } from './tool-loop-guard.js';
181
+ export {
182
+ runTurnLoop, createCallRunner, roundCap, withToolSystem, describeCall as describeToolCall, stepResultText, addUsage, normalizeUsage,
183
+ openAiTranscript, anthropicTranscript,
184
+ DEFAULT_MAX_ROUNDS, DEFAULT_MAX_FINISH_TRIES, FINISH_NUDGES, LOOPING_NUDGE, EXHAUSTED_NOTE, ROUND_SEPARATOR,
185
+ } from './turn-loop.js';
175
186
  export {
176
187
  createResultStore, shieldToolResult, runResultQuery, withResultShield, describeShape, compactValue,
177
188
  resultToolSpec, RESULT_TOOL_NAME, DEFAULT_SHIELD, DEFAULT_STORE,
@@ -202,6 +213,9 @@ export { validateProject, normalizeProject, defineProject, canTransition as canP
202
213
  // Job POSTINGS (F8 §12) — `jobs.js` is the scheduler and keeps `defineJob`; a posting is a JobPost here.
203
214
  export { validateJob as validateJobPost, normalizeJob as normalizeJobPost, defineJob as defineJobPost, canTransition as canJobPostTransition, applyAll, jobToRole, readyJobs, blankJob as blankJobPost, jobFromForm as jobPostFromForm, JobError as JobPostError, JOB_STATUSES as JOB_POST_STATUSES, JOB_ID_RE as JOB_POST_ID_RE } from './job.js';
204
215
  export { validateGate, normalizeGate, effectiveGate, gateAllows, DEFAULT_GATE, AUTONOMY, HUMAN_FLAGS, CHECKS } from './gate.js';
216
+ // The executive loop (F8 §12.2.5): a goal run as a project — jobs, recruiting, rounds run as
217
+ // teams, the review, follow-ups, done-when — every step on the project record.
218
+ export { runProject, roundJobs, teamForRound, jobResults, roundBudget, parseJobs, parseReview, jobsPrompt, reviewPrompt, PROJECT_JOBS_SCHEMA, PROJECT_REVIEW_SCHEMA, ProjectRunError, EXECUTIVE, MAX_JOBS_PER_ROUND, MAX_ROUNDS, PROJECT_RUN_STATUSES } from './project-run.js';
205
219
  export { canonical, sha256, makeEntry, verifyChain, attest, verifyAttested, summarize, fit, adjustSummary, normalizeEngine, engineKey, normalizeScm, SCORECARD_ENTRY_KINDS, ROLE_KINDS, SCORECARD_VERSION, ENGINE_KINDS as RECORD_ENGINE_KINDS } from './scorecard.js';
206
220
  export { ENGINE_KINDS as ENGINE_SPEC_KINDS, ROUTE_PREFERS, normalizePolicy, normalizeEngineSpec, validateEngineSpec, engineRef, engineKeyOf, describeEngine, tierOf } from './engine.js';
207
221
  export { AGENT_ID_RE, APPLIES_TO, EGRESS_CLASSES, ASSISTANT_ID, AgentError, validateAgent, normalizeAgent, defineAgent, assistantAgent, engineOf, describeAgent, slugAgentId, resolveTeam, STARTER_AGENTS, starterAgents, blankAgent, agentFromForm, poolFor } from './agent.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.92.1",
4
- "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
3
+ "version": "0.95.0",
4
+ "description": "The canonical ChatPanel event-log and capability contracts typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -102,6 +102,7 @@
102
102
  "./team-subtask.js": "./team-subtask.js",
103
103
  "./scorecard.js": "./scorecard.js",
104
104
  "./project.js": "./project.js",
105
+ "./project-run.js": "./project-run.js",
105
106
  "./job.js": "./job.js",
106
107
  "./gate.js": "./gate.js",
107
108
  "./team-plan.js": "./team-plan.js",
@@ -119,11 +120,13 @@
119
120
  "./tool-hints.js": "./tool-hints.js",
120
121
  "./tool-need.js": "./tool-need.js",
121
122
  "./tool-result.js": "./tool-result.js",
123
+ "./tool-loop-guard.js": "./tool-loop-guard.js",
122
124
  "./tool-round.js": "./tool-round.js",
123
125
  "./tool-schema.js": "./tool-schema.js",
124
126
  "./tool-traits.js": "./tool-traits.js",
125
127
  "./toolset.js": "./toolset.js",
126
128
  "./trajectory.js": "./trajectory.js",
129
+ "./turn-loop.js": "./turn-loop.js",
127
130
  "./upcast.js": "./upcast.js",
128
131
  "./vault.js": "./vault.js",
129
132
  "./view.js": "./view.js",
@@ -235,6 +238,7 @@
235
238
  "team-subtask.js",
236
239
  "scorecard.js",
237
240
  "project.js",
241
+ "project-run.js",
238
242
  "job.js",
239
243
  "gate.js",
240
244
  "team-plan.js",
@@ -252,11 +256,13 @@
252
256
  "tool-hints.js",
253
257
  "tool-need.js",
254
258
  "tool-result.js",
259
+ "tool-loop-guard.js",
255
260
  "tool-round.js",
256
261
  "tool-schema.js",
257
262
  "tool-traits.js",
258
263
  "toolset.js",
259
264
  "trajectory.js",
265
+ "turn-loop.js",
260
266
  "upcast.js",
261
267
  "vault.js",
262
268
  "view.js",
package/project-run.js ADDED
@@ -0,0 +1,444 @@
1
+ // The executive loop — a goal, run as a project (F8 §12.2.5).
2
+ //
3
+ // A person writes the goal; the executive does what a manager does with it: reads the page,
4
+ // posts the first jobs, recruits for each from the pool, runs the recruited pair as a team
5
+ // against the briefs, reads what came back, posts the follow-up jobs the results call for,
6
+ // asks the stakeholder before anything that spends more or changes scope, and closes when
7
+ // done-when holds. Every step is an event on the project record (project.js `foldProject`),
8
+ // so both clients draw the project page live and the loop is RESUMABLE from the record: a
9
+ // job already done is not redone, one already recruited is not recruited again.
10
+ //
11
+ // This module never speaks to a model, never runs a tool and never stores anything. The
12
+ // host injects: `plan(prompt, schema)` — its structured call on the executive's model;
13
+ // `recruit(job)` — its pool through recruit.js `recruitForRun`; `runJobs({ team, request })`
14
+ // — its team runner (team-run.js through the host's own callModel, so a job's work is a run
15
+ // on the board with threads, a work log and a scorecard fact per member); `ask({ type, text,
16
+ // options })` — the stakeholder. What is asked and what is not is the GATE (gate.js): the
17
+ // loop reads it at every step where it would otherwise ask, and asks a person only where
18
+ // the gate says a person decides.
19
+ //
20
+ // Nothing here is created without a decision: an agent proposed for a job nobody fits goes
21
+ // to the person as a `permission` ask before it exists (§7, D-A2).
22
+
23
+ import { defineSchema, describeSchema, coerce } from './structured.js';
24
+ import { GRANT_RE } from './team.js';
25
+ import { normalizeJob, readyJobs, canTransition as jobCanMove } from './job.js';
26
+ import { emptyProjectRecord, foldProject, projectProgress } from './project.js';
27
+ import { effectiveGate, gateAllows } from './gate.js';
28
+ import { carveBudget } from './recruit.js';
29
+
30
+ export const MAX_JOBS_PER_ROUND = 8;
31
+ export const MAX_ROUNDS = 4;
32
+ export const EXECUTIVE = 'executive';
33
+ export const PROJECT_RUN_STATUSES = Object.freeze(['done', 'open', 'waiting', 'stopped', 'over-budget', 'failed']);
34
+
35
+ export class ProjectRunError extends Error {
36
+ constructor(code, message) { super(message); this.name = 'ProjectRunError'; this.code = code; }
37
+ }
38
+
39
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
40
+ const slug = (s) => String(s || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 48);
41
+
42
+ // ── What the executive answers in ────────────────────────────────────────────────────────
43
+
44
+ const JOB_FIELDS = {
45
+ id: { type: 'string', required: true, max: 48, describe: 'a short id like j1' },
46
+ title: { type: 'string', required: true, max: 120 },
47
+ brief: { type: 'string', required: true, max: 2000, describe: 'what to do and what done looks like — enough for someone who has not read the goal' },
48
+ skills: { type: 'string[]', maxItems: 6, describe: 'skills the job needs, by name (finance, review, …) — used to pick who takes it' },
49
+ grants: { type: 'string[]', maxItems: 6, describe: 'tools it needs: data, web, history, mcp, shell, fs:write, scm:read, scm:push, scm:pr — or none' },
50
+ dependsOn: { type: 'string[]', maxItems: 6, describe: 'job ids whose results this one needs' },
51
+ why: { type: 'string', max: 200, describe: 'why this job, in a few words' },
52
+ };
53
+
54
+ /** The first jobs for a goal, and the follow-ups after a round. */
55
+ export const PROJECT_JOBS_SCHEMA = defineSchema({
56
+ name: 'project_jobs',
57
+ purpose: 'the jobs to post on a project for its goal',
58
+ fields: {
59
+ jobs: { type: 'object[]', required: true, maxItems: MAX_JOBS_PER_ROUND, describe: 'independent where possible; a job that needs another\'s result names it in dependsOn', fields: JOB_FIELDS },
60
+ note: { type: 'string', max: 600, describe: 'what you decided and why, for the project page' },
61
+ },
62
+ nothing: { jobs: [], note: '' },
63
+ });
64
+
65
+ /** After a round: does done-when hold, what is the report, what is still needed. */
66
+ export const PROJECT_REVIEW_SCHEMA = defineSchema({
67
+ name: 'project_review',
68
+ purpose: 'the executive\'s reading of a round of jobs against the project\'s goal and done-when',
69
+ fields: {
70
+ done: { type: 'boolean', required: true, describe: 'true only when done-when holds on the results as they are' },
71
+ report: { type: 'string', required: true, max: 6000, describe: 'the project\'s report so far: what was established, with the job it came from; what was not' },
72
+ followUps: { type: 'object[]', maxItems: MAX_JOBS_PER_ROUND, describe: 'the jobs still needed for done-when to hold — empty when it holds or nothing more would help', fields: JOB_FIELDS },
73
+ why: { type: 'string', max: 400, describe: 'why done or not, in a few words' },
74
+ },
75
+ nothing: { done: false, report: '', followUps: [], why: '' },
76
+ });
77
+
78
+ const poolLines = (pool) => (pool || []).filter((a) => a && a.id && a.enabled !== false).slice(0, 40)
79
+ .map((a) => `- ${a.id}: ${a.name || a.id}${a.purpose ? ` — ${clip(a.purpose, 140)}` : ''}${a.skills?.length ? ` (skills: ${a.skills.join(', ')})` : ''}${a.grants?.length ? ` (tools: ${a.grants.join(', ')})` : ''}`).join('\n');
80
+
81
+ const jobLines = (jobs) => (jobs || []).map((j) => `- ${j.id} [${j.status}]: ${j.title}${j.result?.text ? ` — result: ${clip(j.result.text, 400)}` : ''}`).join('\n');
82
+
83
+ /** The executive's first instruction: the goal, done-when, the pool, the shape. */
84
+ export function jobsPrompt(project, { pool = [], record = null } = {}) {
85
+ const had = (record?.jobs || []).length ? `\nJobs already on the project (do not repeat them):\n${jobLines(record.jobs)}\n` : '';
86
+ return [
87
+ `You are the executive of the project "${project.title}". Post the jobs that get it to done — between 1 and ${MAX_JOBS_PER_ROUND}, independent where possible, each with a brief a stranger could act on.`,
88
+ '',
89
+ `Goal: ${project.goal}`,
90
+ project.doneWhen ? `Done when: ${project.doneWhen}` : '',
91
+ project.budget ? `Budget for the whole project: ${Object.entries(project.budget).map(([k, v]) => `${k} ${v}`).join(', ')}` : '',
92
+ had,
93
+ 'Agents in the pool that may take a job (name the skills and tools a job needs; the fit picks who takes it):',
94
+ poolLines(pool) || '- (nobody yet — name the skills and tools anyway; a job nobody fits is proposed as a new agent)',
95
+ '',
96
+ describeSchema(PROJECT_JOBS_SCHEMA),
97
+ ].filter((l) => l !== null && l !== undefined).join('\n');
98
+ }
99
+
100
+ /** The executive's reading of a round: the goal, done-when, every job's result, the shape. */
101
+ export function reviewPrompt(project, record, { round = 1 } = {}) {
102
+ return [
103
+ `You are the executive of the project "${project.title}". Round ${round} of jobs has finished. Read the results against the goal and done-when. Write the report so far. Say whether done-when HOLDS on these results — not whether it could with more work. If it does not hold and more work would help, post the follow-up jobs (at most ${MAX_JOBS_PER_ROUND}); if nothing more would help, post none and say why.`,
104
+ '',
105
+ `Goal: ${project.goal}`,
106
+ project.doneWhen ? `Done when: ${project.doneWhen}` : 'Done when: (not stated — hold the results to the goal as written)',
107
+ '',
108
+ 'Jobs and their results:',
109
+ jobLines(record?.jobs || []),
110
+ record?.report?.text ? `\nThe report before this round:\n${clip(record.report.text, 3000)}` : '',
111
+ '',
112
+ describeSchema(PROJECT_REVIEW_SCHEMA),
113
+ ].join('\n');
114
+ }
115
+
116
+ /**
117
+ * The executive's answer (text or an already-shaped value) as normalized jobs on the project.
118
+ * Ids the executive reused are suffixed; grants it made up are dropped; a dependency on a job
119
+ * that is not on the project (or on itself) is dropped rather than left dangling.
120
+ */
121
+ export function parseJobs(answer, project, { existing = [], now = Date.now(), by = EXECUTIVE, schema = PROJECT_JOBS_SCHEMA, field = 'jobs' } = {}) {
122
+ const value = typeof answer === 'string' ? coerce(answer, schema)?.value : answer;
123
+ const raw = Array.isArray(value?.[field]) ? value[field] : [];
124
+ const taken = new Set((existing || []).map((j) => j.id));
125
+ const out = [];
126
+ for (const [i, j] of raw.entries()) {
127
+ if (!j || !clip(j.title, 200) || !clip(j.brief, 8000)) continue;
128
+ let id = slug(j.id) || `j${i + 1}`;
129
+ if (!/^[a-z]/.test(id)) id = `j-${id}`;
130
+ let n = 1;
131
+ const base = id;
132
+ while (taken.has(id)) { n += 1; id = `${base}-${n}`; }
133
+ taken.add(id);
134
+ const grants = [...new Set((Array.isArray(j.grants) ? j.grants : []).map((g) => String(g).trim().toLowerCase()).filter((g) => GRANT_RE.test(g) && g !== 'none'))].slice(0, 6);
135
+ try {
136
+ out.push(normalizeJob({
137
+ id, projectId: project.id, title: j.title, brief: j.brief,
138
+ needs: { skills: j.skills, grants, tools: [] },
139
+ dependsOn: Array.isArray(j.dependsOn) ? j.dependsOn.map((d) => slug(d)) : [],
140
+ postedBy: by, postedAt: now, status: 'open',
141
+ ...(j.why ? { origin: { kind: 'executive', why: clip(j.why, 200) } } : {}),
142
+ }));
143
+ } catch { /* a job the form refuses is not posted */ }
144
+ if (out.length >= MAX_JOBS_PER_ROUND) break;
145
+ }
146
+ const ids = new Set([...out.map((j) => j.id), ...(existing || []).map((j) => j.id)]);
147
+ for (const j of out) j.dependsOn = j.dependsOn.filter((d) => ids.has(d) && d !== j.id);
148
+ return out;
149
+ }
150
+
151
+ /** The review, coerced: `{ done, report, followUps: jobs[], why }`. */
152
+ export function parseReview(answer, project, { existing = [], now = Date.now() } = {}) {
153
+ const value = typeof answer === 'string' ? coerce(answer, PROJECT_REVIEW_SCHEMA)?.value : answer;
154
+ if (!value || typeof value !== 'object') return null;
155
+ return {
156
+ done: value.done === true,
157
+ report: clip(value.report, 6000),
158
+ why: clip(value.why, 400),
159
+ followUps: parseJobs(value, project, { existing, now, field: 'followUps', schema: PROJECT_REVIEW_SCHEMA }),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The team a round runs as: one role per recruited job (the role id IS the job id, so two
165
+ * jobs taken by the same agent are two members), the job's dependencies as the role's, a
166
+ * fixed plan (one task per role, the brief as the task), the members' work side by side —
167
+ * the executive reads it, so no judge. The budget is the round's carve.
168
+ */
169
+ export function teamForRound(project, recruited, { budget, round = 1 } = {}) {
170
+ const ids = new Set(recruited.map((r) => r.job.id));
171
+ return {
172
+ name: project.id,
173
+ description: `${project.title} — round ${round}`,
174
+ plan: 'fixed', merge: 'concat',
175
+ roles: recruited.map(({ job, role }) => ({
176
+ ...role,
177
+ id: job.id,
178
+ name: `${role.name || role.agent || job.id} · ${job.title}`.slice(0, 120),
179
+ dependsOn: (job.dependsOn || []).filter((d) => ids.has(d)),
180
+ job: job.id,
181
+ })),
182
+ budget,
183
+ };
184
+ }
185
+
186
+ /** What a round's run says about each job: the task with the job's id, its status and text. */
187
+ export function jobResults(run, jobs, { now = Date.now() } = {}) {
188
+ const out = [];
189
+ for (const job of jobs) {
190
+ const task = (run?.tasks || []).find((t) => t.id === `t_${job.id}` || t.role === job.id);
191
+ if (!task) { out.push({ id: job.id, status: 'failed', result: { text: 'the run never started this job', by: 'runner', at: now } }); continue; }
192
+ const ok = task.status === 'ok';
193
+ const findings = (task.findings || []).map((f) => `- ${f.text}${f.refs?.length ? ` (${f.refs.join(', ')})` : ''}`).join('\n');
194
+ out.push({
195
+ id: job.id,
196
+ status: ok ? 'done' : 'failed',
197
+ result: { text: clip(ok ? (findings || task.text) : (task.error || task.status || 'failed'), 8000), by: task.role || job.id, at: now, refs: [`run:${run.runId}`, ...(task.findings || []).flatMap((f) => f.refs || []).slice(0, 8)] },
198
+ });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * The jobs a round runs: every open job whose dependencies are done — and, with them, every
205
+ * open job whose dependencies are done OR in this round. A chain (facts → memo) is one round
206
+ * and one team; the runner's barriers order it (team-run.js), and the executive reviews the
207
+ * whole chain, not half of it.
208
+ */
209
+ export function roundJobs(jobs) {
210
+ const wave = readyJobs(jobs);
211
+ const inWave = new Set(wave.map((j) => j.id));
212
+ const byId = new Map((jobs || []).map((j) => [j.id, j]));
213
+ for (;;) {
214
+ const more = (jobs || []).filter((j) => j.status === 'open' && !inWave.has(j.id) && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done' || inWave.has(d)));
215
+ if (!more.length) break;
216
+ for (const j of more) { wave.push(j); inWave.add(j.id); }
217
+ }
218
+ return wave;
219
+ }
220
+
221
+ /** The round's budget: what the project has left, split over the jobs that run, never under a floor a run can start with. */
222
+ export function roundBudget(project, record, jobs) {
223
+ const cap = project.budget || {};
224
+ const spent = record?.spend || {};
225
+ const out = {};
226
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
227
+ if (!(Number(cap[k]) > 0)) continue;
228
+ const left = Math.max(0, Number(cap[k]) - (Number(spent[k]) || 0));
229
+ if (left > 0) out[k] = k === 'usd' ? Math.round(left * 100) / 100 : Math.floor(left);
230
+ }
231
+ // A job's own budget caps its share; the round is the sum of the shares, within what is left.
232
+ const shares = jobs.map((j) => carveBudget(j, record)).filter(Boolean);
233
+ if (shares.length === jobs.length) {
234
+ for (const k of Object.keys(out)) {
235
+ const sum = shares.reduce((n, s) => n + (Number(s[k]) || 0), 0);
236
+ if (sum > 0) out[k] = Math.min(out[k], sum);
237
+ }
238
+ }
239
+ return Object.keys(out).length ? out : null;
240
+ }
241
+
242
+ // ── The loop ─────────────────────────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * @param project the page (project.js normalizeProject)
246
+ * @param record the project record so far (foldProject), for a resume; null starts fresh
247
+ * @param pool the agent cards the executive may recruit from (read for the prompt; the
248
+ * host's `recruit` does the pass)
249
+ * @param gate the org's gate (gate.js); the project's own partial gate overrides it
250
+ * @param executive the agent id that runs the loop — on every decision as `by`
251
+ * @param plan `async (prompt, schema) => value | text | null` — the host's structured call
252
+ * @param recruit `async (job, { projectId, create? }) => { role, agentId, engine, why, fit } |
253
+ * { proposal, why } | null` — the host's pool (recruit.js recruitForRun)
254
+ * @param runJobs `async ({ team, request, projectId, round }) => run result` — the host's
255
+ * team runner; `team` is teamForRound's
256
+ * @param ask `async ({ type, text, options }) => { text, by } | null` — the stakeholder;
257
+ * absent, every ask is answered "no" and the loop stops where it would ask
258
+ * @param emit `(type, payload)` — every project-record event, in order; the host lands
259
+ * them on the gateway's project record (and this call folds them too)
260
+ * @param maxRounds rounds of post → recruit → run → review before the loop stops and asks
261
+ */
262
+ export async function runProject({
263
+ project, record = null, pool = [], gate = null, executive = EXECUTIVE,
264
+ plan, recruit = null, runJobs, ask = null, emit = () => {},
265
+ now = () => Date.now(), signal = null, maxRounds = MAX_ROUNDS,
266
+ } = {}) {
267
+ if (!project?.id || !project.goal) throw new ProjectRunError('BAD_PROJECT', 'a project with an id and a goal is required');
268
+ if (typeof plan !== 'function') throw new ProjectRunError('BAD_RUN', 'plan required');
269
+ if (typeof runJobs !== 'function') throw new ProjectRunError('BAD_RUN', 'runJobs required');
270
+ const rec = record && record.id === project.id ? record : emptyProjectRecord({ id: project.id, now: now() });
271
+ if (!rec.page) foldProject(rec, { type: 'project.created', at: now(), payload: { project } });
272
+ const g = effectiveGate(gate, project.gate || null);
273
+ const stopped = () => !!signal?.aborted;
274
+ const say = (type, payload = {}) => { const ev = { type, at: now(), ...payload }; foldProject(rec, { type, at: ev.at, payload }); emit(type, { projectId: project.id, ...ev }); };
275
+ const decide = (kind, text, refs = []) => say('project.decision', { by: executive, kind, text: clip(text, 2000), refs });
276
+ // The recruiter's own events (recruit.js recruitEvents: evaluating → recruited | open + a
277
+ // proposal decision) land on the record as they are.
278
+ const land = (events) => { for (const ev of events || []) { if (!ev?.type) continue; const { type, at: _at, ...payload } = ev; say(type, payload); } };
279
+ const runs = [];
280
+ let rounds = 0;
281
+ const finish = (status, extra = {}) => ({ projectId: project.id, status, rounds, jobs: rec.jobs.map((j) => ({ id: j.id, title: j.title, status: j.status, ...(j.recruited ? { agentId: j.recruited.agentId } : {}), ...(j.runId ? { runId: j.runId } : {}) })), runs, report: rec.report?.text || '', spend: rec.spend, progress: projectProgress(rec), record: rec, ...extra });
282
+
283
+ /** The stakeholder, where the gate says a person decides; recorded either way. */
284
+ const askPerson = async ({ type, text, options }) => {
285
+ decide('ask', `${text}${options?.length ? ` [${options.join(' / ')}]` : ''}`);
286
+ if (typeof ask !== 'function') { decide('answer', 'nobody to ask — taken as no'); return null; }
287
+ let a = null;
288
+ try { a = await ask({ type, text, options }); } catch { a = null; }
289
+ decide('answer', a ? `${a.by || 'person'}: ${a.text}` : 'no answer');
290
+ return a;
291
+ };
292
+ const yes = (a) => !!a && /^(yes|ok|allow|approve|go|post|recruit|create|raise|close|done|continue|run)/i.test(String(a.text || '').trim());
293
+
294
+ /** The executive's structured call, either shape, never a throw. */
295
+ const structured = async (prompt, schema) => { try { const v = await plan(prompt, schema); return typeof v === 'string' ? coerce(v, schema)?.value ?? null : (v && typeof v === 'object' ? v : null); } catch { return null; } };
296
+
297
+ /** Post jobs — the person's say first when the gate wants it. */
298
+ const post = async (jobs, { what }) => {
299
+ if (!jobs.length) return [];
300
+ const lines = jobs.map((j) => `• ${j.id}: ${j.title}${j.needs?.skills?.length ? ` (${j.needs.skills.join(', ')})` : ''}${j.dependsOn?.length ? ` — after ${j.dependsOn.join(', ')}` : ''}`).join('\n');
301
+ // Scope is the stakeholder's (D-A3): the first jobs and any follow-up are posted with
302
+ // their say unless the gate lets the executive recruit on its own.
303
+ if (!gateAllows(g, 'recruit').allowed) {
304
+ const a = await askPerson({ type: 'direction', text: `${what} — post ${jobs.length} job${jobs.length === 1 ? '' : 's'}?\n${lines}`, options: ['Post them', 'Stop here'] });
305
+ if (!yes(a)) return null;
306
+ } else decide('plan', `${what}: ${jobs.length} job${jobs.length === 1 ? '' : 's'} posted\n${lines}`);
307
+ for (const j of jobs) say('job.posted', { job: j, by: executive });
308
+ return jobs;
309
+ };
310
+
311
+ if (rec.status === 'draft') say('project.status', { status: 'open', by: executive });
312
+ if (['done', 'closed'].includes(rec.status)) return finish(rec.status === 'done' ? 'done' : 'stopped', { why: `the project is ${rec.status}` });
313
+ // A RESUME: a job the last loop left recruited or running (its process died) is open
314
+ // again — recruited again, run again; one it finished is not. The record is the checkpoint.
315
+ if (record) {
316
+ const stuck = rec.jobs.filter((j) => ['evaluating', 'recruited', 'in-progress'].includes(j.status));
317
+ for (const j of stuck) say('job.updated', { job: { id: j.id, status: 'open' }, by: executive });
318
+ if (stuck.length) decide('resume', `resumed: ${stuck.map((j) => j.id).join(', ')} back to open (left ${stuck.map((j) => j.status).join(', ')} by the last loop)`);
319
+ else if (rec.jobs.length) decide('resume', `resumed with ${rec.jobs.filter((j) => j.status === 'done').length} of ${rec.jobs.length} jobs done`);
320
+ }
321
+
322
+ // ── 1. the first jobs (skipped on a resume that already has some) ──
323
+ if (!rec.jobs.length) {
324
+ const value = await structured(jobsPrompt(project, { pool, record: rec }), PROJECT_JOBS_SCHEMA);
325
+ const jobs = parseJobs(value || {}, project, { existing: rec.jobs, now: now(), by: executive });
326
+ if (!jobs.length) {
327
+ decide('plan', 'the executive could not turn the goal into jobs');
328
+ const a = await askPerson({ type: 'direction', text: `I could not turn the goal into jobs. Post one job for the whole goal, or stop?`, options: ['Post one job', 'Stop'] });
329
+ if (!yes(a)) return finish('failed', { why: 'no jobs' });
330
+ jobs.push(normalizeJob({ id: 'j1', projectId: project.id, title: clip(project.title, 200), brief: `${project.goal}${project.doneWhen ? `\n\nDone when: ${project.doneWhen}` : ''}`, needs: { skills: [], grants: ['data', 'web'], tools: [] }, postedBy: executive, postedAt: now() }));
331
+ } else if (value?.note) decide('plan', value.note);
332
+ const posted = await post(jobs, { what: 'First jobs' });
333
+ if (!posted) return finish('stopped', { why: 'the stakeholder did not post the first jobs' });
334
+ }
335
+
336
+ // ── rounds: recruit what is ready → run → fold → review → follow-ups ──
337
+ for (;;) {
338
+ if (stopped()) return finish('stopped');
339
+ if (rounds >= maxRounds) {
340
+ decide('review', `${rounds} rounds run; the loop stops here and asks`);
341
+ const a = await askPerson({ type: 'direction', text: `${rounds} rounds have run and done-when does not hold yet. Run another round, or stop with the report so far?`, options: ['Run another round', 'Stop here'] });
342
+ if (!yes(a)) return finish('open', { why: 'rounds exhausted' });
343
+ maxRounds += 1;
344
+ }
345
+ const ready = roundJobs(rec.jobs);
346
+ const stillOpen = rec.jobs.filter((j) => ['open', 'evaluating', 'recruited', 'in-progress'].includes(j.status));
347
+ if (!ready.length) {
348
+ if (!stillOpen.length) return finish(rec.status === 'done' ? 'done' : 'open', { why: 'nothing left to run' });
349
+ // Open jobs whose dependencies failed: they cannot run.
350
+ decide('review', `${stillOpen.length} job${stillOpen.length === 1 ? '' : 's'} cannot run: a dependency failed`);
351
+ for (const j of stillOpen) say('job.updated', { job: { id: j.id, status: 'failed', result: { text: 'a job it depends on failed', by: executive, at: now() } }, by: executive });
352
+ return finish('failed', { why: 'dependencies failed' });
353
+ }
354
+
355
+ // ── 2. recruit ──
356
+ const recruited = [];
357
+ for (const job of ready) {
358
+ if (stopped()) return finish('stopped');
359
+ if (typeof recruit !== 'function') { say('job.updated', { job: { id: job.id, status: 'failed', result: { text: 'this host has no pool to recruit from', by: executive, at: now() } }, by: executive }); continue; }
360
+ let r = null;
361
+ try { r = await recruit(job, { projectId: project.id }); } catch (e) { decide('recruit', `recruiting for "${job.title}" failed: ${clip(e?.message || e, 200)}`, [`job:${job.id}`]); }
362
+ // The recruiter's own events (evaluating → recruited | open + proposal) land on the record.
363
+ land(r?.events);
364
+ if (!r?.role && r?.proposal) {
365
+ // Nobody fits: the agent the job describes, proposed — created only on the person's say.
366
+ const card = r.proposal;
367
+ say('project.decision', { by: executive, kind: 'proposal', text: `No one in the pool fits "${job.title}"${r.why ? ` — ${r.why}` : ''}. Proposed: ${card.name}${card.skills?.length ? ` — skills ${card.skills.join(', ')}` : ''}${card.grants?.length ? `; grants ${card.grants.join(', ')}` : ''}.`, refs: [`job:${job.id}`], proposal: { kind: 'agent', agent: card, jobId: job.id } });
368
+ const allowed = gateAllows(g, 'newAgent').allowed;
369
+ const a = allowed ? { text: 'Create it', by: 'gate' } : await askPerson({ type: 'permission', text: `No one fits "${job.title}". Create the agent "${card.name}" (${[card.skills?.length ? `skills ${card.skills.join(', ')}` : '', card.grants?.length ? `grants ${card.grants.join(', ')}` : ''].filter(Boolean).join('; ') || 'no particular skills'}) and give it the job?`, options: ['Create it', 'Skip'] });
370
+ if (yes(a)) {
371
+ try { r = await recruit(job, { projectId: project.id, create: card }); } catch (e) { decide('recruit', `creating "${card.name}" failed: ${clip(e?.message || e, 200)}`, [`job:${job.id}`]); r = null; }
372
+ land(r?.events);
373
+ }
374
+ }
375
+ if (!r?.role) {
376
+ say('job.updated', { job: { id: job.id, status: 'failed', result: { text: `nobody took it${r?.why ? ` — ${r.why}` : ''}`, by: executive, at: now() } }, by: executive });
377
+ continue;
378
+ }
379
+ // A recruited job is recorded so a resume does not recruit it again (the recruiter's
380
+ // events say so when it landed them; a bare role is recorded here).
381
+ const nowJob = rec.jobs.find((j) => j.id === job.id);
382
+ if (nowJob && nowJob.status !== 'recruited' && jobCanMove(nowJob.status, 'recruited')) say('job.updated', { job: { id: job.id, status: 'recruited', recruited: { agentId: r.agentId || r.role.agent || r.role.id, ...(r.engine ? { engine: r.engine } : {}), by: 'fit', at: now(), ...(r.why ? { why: clip(r.why, 600) } : {}) } }, by: executive });
383
+ decide('recruit', `${r.agentId || r.role.agent || r.role.id} took "${job.title}"${r.why ? ` — ${r.why}` : ''}`, [`job:${job.id}`]);
384
+ recruited.push({ job: rec.jobs.find((j) => j.id === job.id) || job, role: r.role });
385
+ }
386
+ if (!recruited.length) {
387
+ if (rec.jobs.some((j) => ['open', 'recruited'].includes(j.status))) continue; // dependents of a failed job: the next pass fails them
388
+ return finish('failed', { why: 'nobody took any job' });
389
+ }
390
+
391
+ // ── 3. run the round as one team ──
392
+ const jobs = recruited.map((x) => x.job);
393
+ const budget = roundBudget(project, rec, jobs);
394
+ if (!budget) {
395
+ decide('review', 'the project has no budget left for another round');
396
+ const allowed = gateAllows(g, 'budgetRaise').allowed;
397
+ const a = allowed ? null : await askPerson({ type: 'budget', text: `The project has spent its budget (${Object.entries(rec.spend).filter(([, v]) => v).map(([k, v]) => `${k} ${v}`).join(', ')}) with ${jobs.length} job${jobs.length === 1 ? '' : 's'} recruited and not run. Raise it by half, or stop here with the report so far?`, options: ['Raise by half', 'Stop here'] });
398
+ if (!allowed && !yes(a)) return finish('over-budget');
399
+ for (const k of Object.keys(project.budget || {})) project.budget[k] = Math.ceil(project.budget[k] * 1.5);
400
+ say('project.updated', { project: { ...project } });
401
+ decide('answer', `budget raised by half: ${Object.entries(project.budget).map(([k, v]) => `${k} ${v}`).join(', ')}`);
402
+ }
403
+ rounds += 1;
404
+ const team = teamForRound(project, recruited, { budget: roundBudget(project, rec, jobs), round: rounds });
405
+ for (const j of jobs) say('job.updated', { job: { id: j.id, status: 'in-progress' }, by: executive });
406
+ decide('run', `round ${rounds}: ${jobs.map((j) => j.id).join(', ')} run as one team (${Object.entries(team.budget || {}).map(([k, v]) => `${k} ${v}`).join(', ')})`);
407
+ let run = null;
408
+ try {
409
+ run = await runJobs({ team, request: [project.goal, project.doneWhen ? `Done when: ${project.doneWhen}` : ''].filter(Boolean).join('\n'), projectId: project.id, round: rounds, jobs });
410
+ } catch (e) {
411
+ decide('run', `round ${rounds} failed to run: ${clip(e?.message || e, 300)}`);
412
+ }
413
+ if (run?.runId) {
414
+ runs.push({ runId: run.runId, round: rounds, status: run.status });
415
+ for (const j of jobs) say('run.linked', { runId: run.runId, jobId: j.id });
416
+ if (run.usage?.spent) say('run.spent', { runId: run.runId, spent: { ...run.usage.spent, ms: Number(run.usage.spent.ms) || Math.max(0, (run.endedAt || now()) - (run.startedAt || now())) } });
417
+ }
418
+ for (const res of jobResults(run, jobs, { now: now() })) say('job.updated', { job: { ...res, ...(run?.runId ? { runId: run.runId } : {}) }, by: executive });
419
+ if (run && ['stopped', 'waiting'].includes(run.status)) return finish(run.status, { why: `round ${rounds} ${run.status}` });
420
+ if (stopped()) return finish('stopped');
421
+
422
+ // ── 4. review: done-when, the report, the follow-ups ──
423
+ const value = await structured(reviewPrompt(project, rec, { round: rounds }), PROJECT_REVIEW_SCHEMA);
424
+ const review = parseReview(value || {}, project, { existing: rec.jobs, now: now() });
425
+ if (review?.report) say('project.report', { text: review.report, by: executive });
426
+ else say('project.report', { text: rec.jobs.map((j) => `## ${j.title} (${j.status})\n${j.result?.text || ''}`).join('\n\n'), by: 'concat' });
427
+ decide('review', review ? `${review.done ? 'done-when holds' : 'done-when does not hold yet'}${review.why ? ` — ${review.why}` : ''}${review.followUps.length ? `; ${review.followUps.length} follow-up${review.followUps.length === 1 ? '' : 's'}` : ''}` : 'the executive did not answer the review; the report is the results as they are');
428
+ if (review?.done) {
429
+ // Closing is the stakeholder's (project.js: done-when held, a person closed it) —
430
+ // unless the gate lets the executive write back on its own.
431
+ const allowed = gateAllows(g, 'writeBack').allowed;
432
+ const a = allowed ? { text: 'Close as done', by: 'gate' } : await askPerson({ type: 'permission', text: `Done-when holds${review.why ? ` — ${review.why}` : ''}. Close the project as done?`, options: ['Close as done', 'Keep it open'] });
433
+ if (yes(a)) { say('project.status', { status: 'done', by: a.by || 'person' }); return finish('done'); }
434
+ return finish('open', { why: 'done-when holds; left open by the stakeholder' });
435
+ }
436
+ if (!review?.followUps?.length) {
437
+ const left = readyJobs(rec.jobs);
438
+ if (left.length) continue; // jobs queued behind this round run next
439
+ return finish('open', { why: review ? 'nothing more would help' : 'no review' });
440
+ }
441
+ const posted = await post(review.followUps, { what: `After round ${rounds}` });
442
+ if (!posted) return finish('open', { why: 'follow-ups not posted' });
443
+ }
444
+ }
package/team-record.js CHANGED
@@ -35,7 +35,7 @@ export function foldRun(run, ev) {
35
35
  case 'plan.ready':
36
36
  run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
37
37
  // A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
38
- run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : (t.parent && !t.role ? 'unassigned' : 'pending'), findings: taskOf(run, t.id)?.findings || 0, ...(t.parent ? { parent: t.parent, requestedBy: t.requestedBy || null } : {}), ...(t.grants ? { grants: t.grants, why: t.why || '' } : {}) }));
38
+ run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : (t.parent && !t.role ? 'unassigned' : 'pending'), findings: taskOf(run, t.id)?.findings || 0, ...(t.parent ? { parent: t.parent, requestedBy: t.requestedBy || null } : {}), ...(t.grants ? { grants: t.grants, why: t.why || '' } : {}), ...(t.kind ? { kind: t.kind } : {}) }));
39
39
  run.status = 'running';
40
40
  break;
41
41
  // A SUB-TASK (§15.2): requested by a member mid-run, it joins the plan under its parent;
@@ -47,13 +47,29 @@ export function foldRun(run, ev) {
47
47
  run.tasks.push({ id: task.id, role: null, title: task.title, status: 'requested', findings: 0, parent: task.parent, requestedBy: task.requestedBy, needs: task.needs, requestedAt: at });
48
48
  }
49
49
  break;
50
+ // A TASK ADDED AFTER THE PLAN — the merge (the judge's task, opened when the members are
51
+ // done). It joins the plan so a resume carries it and the board draws its thread and log.
52
+ case 'task.added':
53
+ if (p.taskId && !taskOf(run, p.taskId)) {
54
+ const task = { id: p.taskId, role: p.role || null, title: p.title || p.taskId, dependsOn: Array.isArray(p.dependsOn) ? p.dependsOn : [], ...(p.kind ? { kind: p.kind } : {}) };
55
+ if (run.plan) run.plan.tasks = [...(run.plan.tasks || []), task];
56
+ run.tasks.push({ id: task.id, role: task.role, title: task.title, status: 'pending', findings: 0, ...(p.kind ? { kind: p.kind } : {}) });
57
+ }
58
+ break;
50
59
  case 'task.taken': { const t = taskOf(run, p.taskId); if (t) { t.role = p.role; t.status = 'pending'; t.takenBy = { by: p.by || 'fit', role: p.role, fit: p.fit ?? null, reasons: p.reasons || [], agentId: p.agentId || null, engine: p.engine || null, why: p.why || '', at }; } const pt = run.plan?.tasks?.find((x) => x.id === p.taskId); if (pt) pt.role = p.role; break; }
51
60
  case 'task.posted': { const t = taskOf(run, p.taskId); if (t) t.job = p.job || null; if (p.job && !run.jobs.some((j) => j.id === p.job.id)) run.jobs.push({ ...p.job, taskId: p.taskId, at }); break; }
52
61
  case 'task.proposed': { const t = taskOf(run, p.taskId); if (t) t.proposal = { agent: p.agent || null, threadId: p.threadId || null, postId: p.postId || null, why: p.why || '', at }; break; }
53
62
  case 'task.unassigned': { const t = taskOf(run, p.taskId); if (t) { t.status = 'unassigned'; t.error = p.why || null; t.endedAt = at; } const j = run.jobs.find((x) => x.taskId === p.taskId); if (j) j.status = 'failed'; break; }
54
63
  case 'task.nudged': { const t = taskOf(run, p.taskId); if (t) t.nudged = [...(t.nudged || []), { grants: p.grants || [], at }]; break; }
55
64
  case 'run.role-added': if (p.role?.id && !run.roles.includes(p.role.id)) { run.roles.push(p.role.id); run.recruited = [...(run.recruited || []), { ...p.role, jobId: p.jobId || null, at }]; const j = run.jobs.find((x) => x.id === p.jobId); if (j) { j.status = 'recruited'; j.recruited = { agentId: p.role.agent || p.role.id, engine: p.role.engine || null, at }; } } break;
56
- case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
65
+ case 'task.started': {
66
+ // A start the plan never named (a record from a build whose merge was not a task) gets
67
+ // its row here rather than being dropped — the fold never loses a task that ran.
68
+ let t = taskOf(run, p.taskId);
69
+ if (!t && p.taskId) { t = { id: p.taskId, role: p.role || null, title: p.title || p.taskId, status: 'pending', findings: 0, ...(p.taskId === 'merge' ? { kind: 'merge' } : {}) }; run.tasks.push(t); }
70
+ if (t) { t.status = 'running'; t.startedAt = at; t.error = null; }
71
+ run.status = 'running'; break;
72
+ }
57
73
  case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
58
74
  case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
59
75
  case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }