in2-agent-server 0.20260821.185429
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 +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// advance_phase / resume_session — the chapter boundary, and the way back into a run that died.
|
|
2
|
+
//
|
|
3
|
+
// ONE TOOL AT THE BOUNDARY DOES THREE THINGS AT ONCE, and they belong together because they are all
|
|
4
|
+
// answers to the same question — "the run just moved; what follows from that?"
|
|
5
|
+
//
|
|
6
|
+
// 1. IT RECORDS THE POSITION. `phases.jsonl` is the only artifact that says where a run had got
|
|
7
|
+
// to. Everything else in a session directory says what a run PRODUCED, and a chapter entered
|
|
8
|
+
// but never finished leaves no trace at all.
|
|
9
|
+
// 2. IT SHIPS WHAT HAS HAPPENED SO FAR. `close_batch` was the only flush, and it runs at the end,
|
|
10
|
+
// so a run that died in the middle shipped nothing — see `flushAtBoundary` in core/telemetry.ts
|
|
11
|
+
// for the measurement. Eight boundaries precede the end; each one is a free, already-motivated
|
|
12
|
+
// moment to drain the outbox and upload the transcript.
|
|
13
|
+
// 3. IT HANDS BACK THE NEXT CHAPTER'S INSTRUCTIONS. The resident contract was cut to a skeleton on
|
|
14
|
+
// measured evidence that clients truncate it at ~2 KB, and the expanded per-step detail had
|
|
15
|
+
// nowhere to arrive on time. A tool return arrives exactly when the instruction applies. See
|
|
16
|
+
// `core/chapterInstructions.ts`.
|
|
17
|
+
//
|
|
18
|
+
// WHY NOT SPLIT THEM. Any two of the three without the third is worse than the whole: a ledger
|
|
19
|
+
// nobody has a reason to write is a ledger that goes stale; a flush with no boundary to hang on has
|
|
20
|
+
// to be a timer, which is the design that already fails on a dying process; and instructions handed
|
|
21
|
+
// out on request are instructions the model asks for only when it already knows what it is doing.
|
|
22
|
+
// Binding all three to the boundary is what makes each one happen reliably — the model calls it for
|
|
23
|
+
// the instructions, and the position and the flush ride along.
|
|
24
|
+
//
|
|
25
|
+
// IT NEVER REPLACES `close_batch`. This tool records where a run STANDS; `close_batch` states its
|
|
26
|
+
// terminal RESULT, assembles the closing evidence and ships everything that was waiting on the end.
|
|
27
|
+
// Advancing into `writeback-close` is not closing the batch, and the refusal and the return text
|
|
28
|
+
// both say so — a run that stopped at the last boundary still reads ABANDONED on the dashboard.
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { chapterBrief } from "../core/chapterInstructions.js";
|
|
31
|
+
import { fail, internalError, ok } from "../core/result.js";
|
|
32
|
+
import { CHAPTERS, appendPhaseLedger, isChapter, ledgerHead, newLedgerEntry, planTransition, readPhaseLedger } from "../core/phases.js";
|
|
33
|
+
import { reconstruct, renderReconstruction } from "../core/resume.js";
|
|
34
|
+
import { rebindStoredBatch } from "../core/telemetry.js";
|
|
35
|
+
import { currentBatchContext, emitDomainTelemetryEvent, flushAtBoundary, processTelemetryIdentity } from "../core/telemetryContext.js";
|
|
36
|
+
import { WorkspaceError, fileExists, sessionFile } from "../core/workspace.js";
|
|
37
|
+
import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
|
|
38
|
+
import { loadSession } from "./session.js";
|
|
39
|
+
const CHAPTER_LIST = CHAPTERS.join(" -> ");
|
|
40
|
+
export function registerPhaseTools(server) {
|
|
41
|
+
server.registerTool("advance_phase", {
|
|
42
|
+
title: "Cross a chapter boundary of the delivery run",
|
|
43
|
+
description: "CALL THIS AT EVERY CHAPTER BOUNDARY OF A DELIVERY RUN, naming the chapter you just finished and the one you are entering. " +
|
|
44
|
+
`The nine chapters, in order: ${CHAPTER_LIST}. ` +
|
|
45
|
+
"RETURNS THE ENTERING CHAPTER'S FULL INSTRUCTIONS — the expanded doctrine for the work you are about to do, which is deliberately NOT in the resident contract (clients truncate that, so it carries only the loop skeleton). Read what comes back and follow it; it is more specific than the playbook's summary of the same step. " +
|
|
46
|
+
"It also returns a progress line in the operator's language: relay that to them, do not paraphrase harness internals. " +
|
|
47
|
+
"AND IT SHIPS THE RUN'S EVIDENCE SO FAR — the telemetry outbox, any queued provenance parcels, and the session transcript. That is why calling it matters even on a run you are certain will finish: a run that dies mid-flight has otherwise shipped nothing at all, because the only other flush is at close. " +
|
|
48
|
+
"Small batches legitimately skip partitioning, parallel-analysis and clarification; naming a further chapter jumps them and is recorded. Every other chapter is load-bearing and skipping it is refused by name. Going backwards is refused. " +
|
|
49
|
+
"THIS IS NOT close_batch AND NEVER REPLACES IT. This records where the run STANDS; close_batch states the batch's terminal RESULT, writes the closing evidence and ships everything waiting on the end of the run. Advancing into writeback-close is not closing the batch — a run you do not close still reads ABANDONED on the IN2 dashboard.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
sessionId: z.string().describe("The session whose run is moving. The org and the batch are derived from it, as everywhere else."),
|
|
52
|
+
completed: z
|
|
53
|
+
.enum(CHAPTERS)
|
|
54
|
+
.describe(`The chapter you have just FINISHED. One of: ${CHAPTER_LIST}.`),
|
|
55
|
+
entering: z
|
|
56
|
+
.enum(CHAPTERS)
|
|
57
|
+
.describe("The chapter you are entering — normally the next one. Naming a later chapter records the ones in between as SKIPPED, which is legal for partitioning, parallel-analysis and clarification (a batch small enough to analyze inline, or one that raises no genuine fork) and refused for the rest."),
|
|
58
|
+
note: z
|
|
59
|
+
.string()
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("One line about what this chapter produced or why a skip was taken, recorded on the ledger. A resumed session reads it. Not shown to the operator — the progress line is.")
|
|
62
|
+
}
|
|
63
|
+
}, async ({ sessionId, completed, entering, note }) => {
|
|
64
|
+
try {
|
|
65
|
+
// Fail before anything is recorded if there is no session to record it against. Creating the
|
|
66
|
+
// ledger under a session that does not exist would leave a phases.jsonl beside nothing.
|
|
67
|
+
if (!fileExists(sessionFile(sessionId))) {
|
|
68
|
+
return fail("SESSION_NOT_FOUND", `No session ${sessionId}. Start one with activate_session {code} (a pasted Campfire code) or create_session, then advance from activation.`);
|
|
69
|
+
}
|
|
70
|
+
const ledger = await readPhaseLedger(sessionId);
|
|
71
|
+
const head = ledgerHead(ledger);
|
|
72
|
+
const transition = planTransition({ head, completed, entering });
|
|
73
|
+
if (!transition.ok)
|
|
74
|
+
return fail(transition.code, transition.message);
|
|
75
|
+
const context = currentBatchContext();
|
|
76
|
+
await appendPhaseLedger(newLedgerEntry({
|
|
77
|
+
kind: "advance",
|
|
78
|
+
sessionId,
|
|
79
|
+
completed,
|
|
80
|
+
entering,
|
|
81
|
+
skipped: transition.skipped,
|
|
82
|
+
...(note ? { note } : {}),
|
|
83
|
+
...(processTelemetryIdentity()?.runId ? { runId: processTelemetryIdentity().runId } : {}),
|
|
84
|
+
...(context?.batchId ? { batchId: context.batchId } : {})
|
|
85
|
+
}));
|
|
86
|
+
// Shape only, exactly like every other domain event: which boundary, and what was skipped.
|
|
87
|
+
// No note text — the note is the supervisor's prose and belongs on local disk, where the
|
|
88
|
+
// rest of this run's prose lives.
|
|
89
|
+
emitDomainTelemetryEvent("run.phase_advanced", {
|
|
90
|
+
completed,
|
|
91
|
+
entering,
|
|
92
|
+
...(transition.skipped.length > 0 ? { skipped: transition.skipped } : {})
|
|
93
|
+
}, { stage: "boundary", outcome: entering });
|
|
94
|
+
// AFTER the ledger append, deliberately. The append is local and synchronous-ish and cannot
|
|
95
|
+
// fail on a network; the flush is network work that may take seconds. Recording the position
|
|
96
|
+
// first means a boundary crossed on a machine that then loses its connection still leaves the
|
|
97
|
+
// ledger correct, which is the artifact a resume depends on.
|
|
98
|
+
const flush = await flushAtBoundary();
|
|
99
|
+
const brief = chapterBrief(entering);
|
|
100
|
+
const text = [
|
|
101
|
+
`${completed} -> ${entering}${transition.skipped.length > 0 ? ` (skipped: ${transition.skipped.join(", ")})` : ""}`,
|
|
102
|
+
"",
|
|
103
|
+
`## ${brief.headline}`,
|
|
104
|
+
"",
|
|
105
|
+
brief.instructions,
|
|
106
|
+
"",
|
|
107
|
+
"---",
|
|
108
|
+
"",
|
|
109
|
+
"RELAY THIS TO THE OPERATOR, in their words, before you start the work above:",
|
|
110
|
+
` ${brief.progressLine}`,
|
|
111
|
+
"",
|
|
112
|
+
...(flush
|
|
113
|
+
? ["Evidence shipped at this boundary:", ...flush.notes.map((n) => ` - ${n}`)]
|
|
114
|
+
: ["Telemetry is disabled on this install (CC_MCP_TELEMETRY=0) — nothing was shipped, and nothing was expected to be."]),
|
|
115
|
+
...(entering === "writeback-close"
|
|
116
|
+
? [
|
|
117
|
+
"",
|
|
118
|
+
"THIS IS THE LAST CHAPTER, AND CROSSING INTO IT IS NOT CLOSING THE BATCH. `close_batch {sessionId}`",
|
|
119
|
+
"is still required, as the LAST tool call of the run, after the writeback — and equally if the run",
|
|
120
|
+
"ends badly from here. Nothing else states the batch's result or ships its closing evidence."
|
|
121
|
+
]
|
|
122
|
+
: [])
|
|
123
|
+
].join("\n");
|
|
124
|
+
return ok(text, {
|
|
125
|
+
sessionId,
|
|
126
|
+
completed,
|
|
127
|
+
entering,
|
|
128
|
+
...(transition.skipped.length > 0 ? { skipped: transition.skipped } : {}),
|
|
129
|
+
chapter: brief.headline,
|
|
130
|
+
progressLine: brief.progressLine,
|
|
131
|
+
shipped: flush?.notes ?? [],
|
|
132
|
+
batchClosed: false
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof WorkspaceError)
|
|
137
|
+
return fail(error.code, error.message);
|
|
138
|
+
return internalError(error);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
server.registerTool("resume_session", {
|
|
142
|
+
title: "Pick up a delivery run that a previous session left unfinished",
|
|
143
|
+
description: "READ A DEAD RUN BACK. Given only a sessionId, this reconstructs where the run stands — the chapter it was in and how long ago it stopped, the requirements and which have plans, the analysis brief, the question rounds and whether any are still open, and every wave that reached the org with its verdict — and returns instructions for picking it up. " +
|
|
144
|
+
"Use it when an operator returns to a run a previous conversation started: a crashed client, a closed window, a session resumed the next morning. Nothing in a session directory records POSITION on its own, so do not infer it from artifacts yourself; a chapter entered but never finished looks exactly like one never entered, and guessing wrong in that direction means deploying work nobody approved. " +
|
|
145
|
+
"IT CHECKS THE ORG AUTH RATHER THAN ASSUMING IT. An activation-minted token lasts about a day, so a run resumed the next morning usually has a DEAD org even though every artifact is intact. The result says whether the org is reachable and, when it is not, that you must card the operator rather than re-authenticate anything yourself. " +
|
|
146
|
+
"READ-ONLY: it writes one `resume` line to the phase ledger and changes nothing else. No org call mutates anything, no artifact is rewritten, and the batch is not closed — you still call close_batch at the end of the resumed run.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
sessionId: z.string().describe("The session to pick up. It is the only thing you need; everything else is derived from disk.")
|
|
149
|
+
}
|
|
150
|
+
}, async ({ sessionId }) => {
|
|
151
|
+
try {
|
|
152
|
+
if (!fileExists(sessionFile(sessionId))) {
|
|
153
|
+
return fail("SESSION_NOT_FOUND", `No session ${sessionId} on this machine. A session's workspace is local, so a run started on another machine cannot be resumed here — the operator starts a fresh one from Campfire.`);
|
|
154
|
+
}
|
|
155
|
+
const state = await reconstruct(sessionId);
|
|
156
|
+
const auth = await checkAuth(sessionId, state.batchId);
|
|
157
|
+
await appendPhaseLedger(newLedgerEntry({
|
|
158
|
+
kind: "resume",
|
|
159
|
+
sessionId,
|
|
160
|
+
entering: state.resumeInto,
|
|
161
|
+
note: auth.usable ? "resumed; org reachable" : `resumed; org NOT reachable (${auth.summary})`,
|
|
162
|
+
...(processTelemetryIdentity()?.runId ? { runId: processTelemetryIdentity().runId } : {}),
|
|
163
|
+
...(state.batchId ? { batchId: state.batchId } : {})
|
|
164
|
+
}));
|
|
165
|
+
emitDomainTelemetryEvent("run.resumed", {
|
|
166
|
+
chapter: state.resumeInto,
|
|
167
|
+
ledgerPresent: state.chapter !== null,
|
|
168
|
+
orgReachable: auth.usable,
|
|
169
|
+
requirementCount: state.requirements.length,
|
|
170
|
+
waveCount: state.waves.length,
|
|
171
|
+
discrepancyCount: state.discrepancies.length
|
|
172
|
+
}, { stage: "boundary", outcome: auth.usable ? "resumed" : "resumed-no-org" });
|
|
173
|
+
return ok(renderReconstruction(state, auth), {
|
|
174
|
+
sessionId,
|
|
175
|
+
chapter: state.chapter,
|
|
176
|
+
resumeInto: state.resumeInto,
|
|
177
|
+
orgReachable: auth.usable,
|
|
178
|
+
telemetryRebound: auth.telemetryRebound,
|
|
179
|
+
...(state.orgAlias ? { orgAlias: state.orgAlias } : {}),
|
|
180
|
+
...(state.batchId ? { batchId: state.batchId } : {}),
|
|
181
|
+
requirements: state.requirements,
|
|
182
|
+
rounds: state.rounds,
|
|
183
|
+
waves: state.waves,
|
|
184
|
+
...(state.waveGrade ? { waveGrade: state.waveGrade } : {}),
|
|
185
|
+
...(state.discrepancies.length > 0 ? { discrepancies: state.discrepancies } : {}),
|
|
186
|
+
batchClosed: false
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error instanceof WorkspaceError)
|
|
191
|
+
return fail(error.code, error.message);
|
|
192
|
+
return internalError(error);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* IS THIS RUN STILL ABLE TO ACT — asked, never assumed, and asked FIRST.
|
|
198
|
+
*
|
|
199
|
+
* THE AUTH QUESTION IS THE ONE A RESUME CANNOT SKIP. Every artifact in a session directory survives
|
|
200
|
+
* indefinitely; the org token does not. An activation-minted token lasts about a day, so the single
|
|
201
|
+
* most common resume — an operator returning the next morning — is precisely the case where every
|
|
202
|
+
* plan, brief and bundle is intact and the org is unreachable. A resume that assumed the old auth
|
|
203
|
+
* was alive would report a run ready to continue and then fail at the first projection, having spent
|
|
204
|
+
* the operator's attention getting there.
|
|
205
|
+
*
|
|
206
|
+
* So the check is a real read (`resolveSessionOrg` with verification, which resolves the alias
|
|
207
|
+
* through the CLI) and its failure is a CARD, never a repair. Nothing here re-authenticates: rapid
|
|
208
|
+
* retries trip Salesforce's integration-user auto-freeze, and the fix — a fresh Campfire activation,
|
|
209
|
+
* or a login the operator performs — is theirs by design.
|
|
210
|
+
*
|
|
211
|
+
* THE SECOND, QUIETER AUTH: the batch GRANT, which is what lets a resumed run ship its evidence.
|
|
212
|
+
* Activation binds it once and this process never saw that activation, so without a re-bind a
|
|
213
|
+
* resumed run's `close_batch` reports "no batch bound" and its events and transcript stay on the
|
|
214
|
+
* machine. `rebindStoredBatch` reads the grant this install was already issued for this batch. It
|
|
215
|
+
* can fail independently of the org — the grant outlives the org token, or expires while the org
|
|
216
|
+
* stays fine — so the two are reported as two facts rather than one verdict.
|
|
217
|
+
*/
|
|
218
|
+
async function checkAuth(sessionId, batchId) {
|
|
219
|
+
const telemetryRebound = batchId ? rebindStoredBatch({ batchId, sessionId }) : false;
|
|
220
|
+
try {
|
|
221
|
+
const session = await loadSession(sessionId);
|
|
222
|
+
const { alias, orgId } = await resolveSessionOrg(session, "target", { verify: true });
|
|
223
|
+
return {
|
|
224
|
+
usable: true,
|
|
225
|
+
telemetryRebound,
|
|
226
|
+
summary: `alias ${alias} still resolves to org ${orgId} — the session's org auth is alive and this run can read and deploy.`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error instanceof SessionOrgError) {
|
|
231
|
+
return {
|
|
232
|
+
usable: false,
|
|
233
|
+
telemetryRebound,
|
|
234
|
+
summary: `${error.code} — ${error.message}`
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// Everything else from this path is the org being unreachable: an expired token, a logged-out
|
|
238
|
+
// alias, an sf CLI that cannot answer. Reported as unusable rather than swallowed, because
|
|
239
|
+
// "we could not tell" and "it works" must never look the same here.
|
|
240
|
+
return {
|
|
241
|
+
usable: false,
|
|
242
|
+
telemetryRebound,
|
|
243
|
+
summary: `the session's org could not be reached (${error instanceof Error ? error.message : String(error)}). An activation-minted token lasts about a day, so a run resumed the next morning normally reads this way. The operator re-activates from Campfire for a fresh code, or signs in directly with \`sf org login web --alias <the session alias> --instance-url <their org url>\`. You never re-authenticate an org yourself.`
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Exported for the doctrine test: every chapter the tool accepts must have a brief behind it. */
|
|
248
|
+
export const ACCEPTED_CHAPTERS = CHAPTERS;
|
|
249
|
+
export { isChapter };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { fail, internalError, ok } from "../core/result.js";
|
|
5
|
+
import { WorkspaceError } from "../core/workspace.js";
|
|
6
|
+
import { appendReadRecord } from "../core/readsLog.js";
|
|
7
|
+
import { loadSession } from "./session.js";
|
|
8
|
+
import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
|
|
9
|
+
import { sessionDir } from "../core/workspace.js";
|
|
10
|
+
import { OrgReaderUnavailableError, createLiveOrgReader } from "../projection/bridge.js";
|
|
11
|
+
import { ScopeTooLargeError } from "../projection/project.js";
|
|
12
|
+
import { runProjection } from "../projection/project.js";
|
|
13
|
+
import { diffProjections, renderDiff } from "../projection/diff.js";
|
|
14
|
+
import { scopeIsEmpty } from "../projection/scope.js";
|
|
15
|
+
const DEFAULT_API_VERSION = "61.0";
|
|
16
|
+
const LABEL_RE = /^[A-Za-z0-9_-]{1,40}$/;
|
|
17
|
+
function timestampLabel() {
|
|
18
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
19
|
+
}
|
|
20
|
+
export function registerProjectionTools(server) {
|
|
21
|
+
server.registerTool("project_org", {
|
|
22
|
+
description: "READ-ONLY org projection: materialize a scoped slice of an org's metadata into workspace files a model can read, grep, and diff — canonical per-component JSON via the verifier's OWN snapshot readers (the gate's read path, same normalizations), plus MAP.md (the entry point: one line per component with load-bearing facets) and a fail-closed projection.json manifest. Scope = objects (object + fields + validation rules + layout list) and/or prefix (objects matching an API-name prefix) and/or explicit components. Pass compareTo (a prior projection dir) to also emit DIFF.md/diff.json — self-diff of an unchanged org is empty. Use before compiling brownfield requirements: project the touched objects and assert org actuals from the projection.",
|
|
23
|
+
inputSchema: {
|
|
24
|
+
sessionId: z
|
|
25
|
+
.string()
|
|
26
|
+
.describe("The session to project for. The org comes FROM the session (bound by activate_session / create_session), and the projection is stored under that session's workspace."),
|
|
27
|
+
scope: z
|
|
28
|
+
.object({
|
|
29
|
+
objects: z.array(z.string()).optional().describe("Object API names — each expands to object + custom fields + validation rules + layout list."),
|
|
30
|
+
prefix: z.string().optional().describe("Object API-name prefix, e.g. CF63_ (SOQL-wildcard-escaped)."),
|
|
31
|
+
components: z
|
|
32
|
+
.array(z.object({ typeName: z.string(), fullName: z.string() }))
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Explicit components, e.g. {typeName: Layout, fullName: 'CF63_Intake_Request__c-CF63 Layout'}.")
|
|
35
|
+
})
|
|
36
|
+
.describe("At least one of objects/prefix/components."),
|
|
37
|
+
label: z.string().optional().describe("Directory label [A-Za-z0-9_-]{1,40}; default = timestamp."),
|
|
38
|
+
compareTo: z.string().optional().describe("Absolute path of a prior projection dir to diff against."),
|
|
39
|
+
apiVersion: z.string().optional().describe("Metadata API version; default 61.0."),
|
|
40
|
+
maxComponents: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.positive()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Refuse if the scope resolves beyond this many components (default 250). Raise it only after seeing the refusal's breakdown — a standard object like Account contributes hundreds of custom fields.")
|
|
46
|
+
}
|
|
47
|
+
}, async ({ scope, sessionId, label, compareTo, apiVersion, maxComponents }) => {
|
|
48
|
+
try {
|
|
49
|
+
const session = await loadSession(sessionId);
|
|
50
|
+
// Read-only, so the alias check is skipped: a projection of the "wrong" org is harmless and
|
|
51
|
+
// self-evident in the output, and paying an sf round-trip on every projection is not.
|
|
52
|
+
// Writes (direct_apply) always verify.
|
|
53
|
+
const binding = await resolveSessionOrg(session, "target", { verify: false });
|
|
54
|
+
const targetOrg = binding.alias;
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
if (scopeIsEmpty(scope)) {
|
|
57
|
+
return fail("SCOPE_EMPTY", "Provide at least one of scope.objects, scope.prefix, scope.components.");
|
|
58
|
+
}
|
|
59
|
+
if (label && !LABEL_RE.test(label)) {
|
|
60
|
+
return fail("INVALID_LABEL", `label must match ${LABEL_RE}.`);
|
|
61
|
+
}
|
|
62
|
+
if (compareTo && !existsSync(path.join(compareTo, "projection.json"))) {
|
|
63
|
+
return fail("PROJECTION_NOT_FOUND", `compareTo has no projection.json: ${compareTo}`);
|
|
64
|
+
}
|
|
65
|
+
const version = apiVersion?.trim() || DEFAULT_API_VERSION;
|
|
66
|
+
const dirLabel = label ?? timestampLabel();
|
|
67
|
+
// sessionId is required now, so the old shared-root fallback was unreachable.
|
|
68
|
+
const root = path.join(sessionDir(sessionId), "org-projection");
|
|
69
|
+
const outDir = path.join(root, targetOrg, dirLabel);
|
|
70
|
+
const reader = await createLiveOrgReader(targetOrg, version);
|
|
71
|
+
try {
|
|
72
|
+
await reader.identity();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// Fail-closed: an unreadable org is an error, never an empty projection.
|
|
76
|
+
return fail("ORG_READ_FAILED", `Cannot read org '${targetOrg}': ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
}
|
|
78
|
+
const result = await runProjection(reader, {
|
|
79
|
+
scope: scope,
|
|
80
|
+
outDir,
|
|
81
|
+
orgAlias: targetOrg,
|
|
82
|
+
apiVersion: version,
|
|
83
|
+
label: dirLabel,
|
|
84
|
+
maxComponents
|
|
85
|
+
});
|
|
86
|
+
// READ PROVENANCE (R9-R10, R12). A projection materializes each component's full snapshot,
|
|
87
|
+
// so its access pattern is a BODY read — the strongest thing a contamination report can say
|
|
88
|
+
// about a component, and the reason enumerating an object is not the same event as this.
|
|
89
|
+
//
|
|
90
|
+
// A POINTER, not a component list. The manifest this call just wrote already names every
|
|
91
|
+
// component with its found flag; copying that into reads.jsonl would duplicate a list that
|
|
92
|
+
// can run to hundreds of entries per wave and then have to be kept in sync with it. The
|
|
93
|
+
// read-set carries the path and resolves it when a report needs the members.
|
|
94
|
+
await appendReadRecord(sessionId, {
|
|
95
|
+
tool: "project_org",
|
|
96
|
+
// The manifest records the org id the READER observed; the session binding is what the
|
|
97
|
+
// rest of the log is keyed on, so they must not be mixed.
|
|
98
|
+
orgId: binding.orgId ?? null,
|
|
99
|
+
orgAlias: targetOrg,
|
|
100
|
+
scope: `projection ${dirLabel}`,
|
|
101
|
+
pattern: "body",
|
|
102
|
+
keys: [],
|
|
103
|
+
manifests: [path.join(outDir, "projection.json")],
|
|
104
|
+
rows: result.manifest.counts.components,
|
|
105
|
+
durationMs: Date.now() - startedAt,
|
|
106
|
+
outcome: "ok"
|
|
107
|
+
});
|
|
108
|
+
let diffSummary;
|
|
109
|
+
const textLines = [
|
|
110
|
+
`projected ${result.manifest.counts.components} components (${result.manifest.counts.errors} errors) from ${targetOrg} (org ${result.manifest.orgId})`,
|
|
111
|
+
`map: ${path.join(outDir, "MAP.md")}`,
|
|
112
|
+
`manifest: ${path.join(outDir, "projection.json")}`
|
|
113
|
+
];
|
|
114
|
+
// Ambiguity rides the RESPONSE, not just MAP.md. The whole failure mode is a supervisor who
|
|
115
|
+
// never learns a choice was made — leaving the notice in a file they may not open would
|
|
116
|
+
// reproduce it one layer up.
|
|
117
|
+
const ambiguities = result.manifest.scopeAmbiguities ?? [];
|
|
118
|
+
for (const a of ambiguities) {
|
|
119
|
+
textLines.push(`AMBIGUOUS SCOPE NAME — ${a.typeName}: ${a.requestedName} — ${a.note}`);
|
|
120
|
+
}
|
|
121
|
+
if (compareTo) {
|
|
122
|
+
const diff = await diffProjections(compareTo, outDir);
|
|
123
|
+
await fs.writeFile(path.join(outDir, "diff.json"), `${JSON.stringify(diff, null, 2)}\n`, "utf8");
|
|
124
|
+
await fs.writeFile(path.join(outDir, "DIFF.md"), renderDiff(diff), "utf8");
|
|
125
|
+
diffSummary = {
|
|
126
|
+
added: diff.added.length,
|
|
127
|
+
removed: diff.removed.length,
|
|
128
|
+
changed: diff.changed.length,
|
|
129
|
+
diffPath: path.join(outDir, "DIFF.md")
|
|
130
|
+
};
|
|
131
|
+
textLines.push(`diff vs ${compareTo}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed → ${path.join(outDir, "DIFF.md")}`);
|
|
132
|
+
}
|
|
133
|
+
return ok(textLines.join("\n"), {
|
|
134
|
+
dir: outDir,
|
|
135
|
+
mapPath: path.join(outDir, "MAP.md"),
|
|
136
|
+
counts: result.manifest.counts,
|
|
137
|
+
enumerationErrors: result.manifest.enumerationErrors,
|
|
138
|
+
...(ambiguities.length ? { scopeAmbiguities: ambiguities } : {}),
|
|
139
|
+
...(diffSummary ? { diff: diffSummary } : {})
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// Name the cost drivers. A bare "too large" would send the supervisor guessing, and the
|
|
144
|
+
// guess is usually wrong — the expensive entry looks identical to the cheap ones in the
|
|
145
|
+
// scope they wrote.
|
|
146
|
+
if (error instanceof ScopeTooLargeError) {
|
|
147
|
+
const worst = error.breakdown
|
|
148
|
+
.filter((o) => o.customFields > 0)
|
|
149
|
+
.slice(0, 5)
|
|
150
|
+
.map((o) => ` ${o.objectApiName}: ${o.customFields} custom fields`)
|
|
151
|
+
.join("\n");
|
|
152
|
+
return fail("SCOPE_TOO_LARGE", [
|
|
153
|
+
`This scope resolves to ${error.total} components, over the ${error.limit} limit.`,
|
|
154
|
+
worst ? `Biggest contributors:\n${worst}` : "",
|
|
155
|
+
"Each custom field is a separate component. Standard objects such as Account and Contact",
|
|
156
|
+
"carry hundreds, so adding one to scope costs far more than it appears to.",
|
|
157
|
+
"Narrow the scope to the objects the batch actually touches, or pass maxComponents to",
|
|
158
|
+
"raise the limit deliberately."
|
|
159
|
+
]
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join("\n"));
|
|
162
|
+
}
|
|
163
|
+
if (error instanceof OrgReaderUnavailableError)
|
|
164
|
+
return fail("ORG_READER_UNAVAILABLE", error.message);
|
|
165
|
+
if (error instanceof SessionOrgError)
|
|
166
|
+
return fail(error.code, error.message);
|
|
167
|
+
if (error instanceof WorkspaceError)
|
|
168
|
+
return fail(error.code, error.message);
|
|
169
|
+
return internalError(error);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|