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,313 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { semanticContentHash } from "../core/semanticHash.js";
|
|
5
|
+
import { opaqueId, isoNow } from "../core/ids.js";
|
|
6
|
+
import { decodeHtmlEntities } from "../core/richText.js";
|
|
7
|
+
import { ok, fail, internalError } from "../core/result.js";
|
|
8
|
+
import { REPO_ROOT } from "../core/paths.js";
|
|
9
|
+
import { runProcess, sfCliEnv } from "../core/spawn.js";
|
|
10
|
+
import { sfQueryTruncation } from "../core/sfQuery.js";
|
|
11
|
+
import { WorkspaceError, ensureDir, fileExists, readJson, requirementDir, sessionDir, sessionFile, writeJsonAtomic, writeTextAtomic } from "../core/workspace.js";
|
|
12
|
+
export const SESSION_SCHEMA_VERSION = "cc-session-v1";
|
|
13
|
+
let cachedCommit = null;
|
|
14
|
+
export async function producerIdentity() {
|
|
15
|
+
if (!cachedCommit) {
|
|
16
|
+
const run = await runProcess("git", ["rev-parse", "HEAD"], {
|
|
17
|
+
cwd: REPO_ROOT,
|
|
18
|
+
timeoutMs: 10_000
|
|
19
|
+
});
|
|
20
|
+
const commit = run.exitCode === 0 ? run.stdout.trim() : "";
|
|
21
|
+
cachedCommit = /^[a-f0-9]{7,64}$/.test(commit) ? commit : "a000000";
|
|
22
|
+
}
|
|
23
|
+
return { name: "cc-mcp-server", version: "0.1.0", monorepoCommit: cachedCommit };
|
|
24
|
+
}
|
|
25
|
+
export async function loadSession(sessionId) {
|
|
26
|
+
const file = sessionFile(sessionId);
|
|
27
|
+
if (!fileExists(file)) {
|
|
28
|
+
throw new WorkspaceError("SESSION_NOT_FOUND", `No session at ${file}. Call create_session first.`);
|
|
29
|
+
}
|
|
30
|
+
return readJson(file);
|
|
31
|
+
}
|
|
32
|
+
export function requirementsIndexFile(sessionId) {
|
|
33
|
+
return path.join(sessionDir(sessionId), "requirements", "index.json");
|
|
34
|
+
}
|
|
35
|
+
export async function loadRequirementsIndex(sessionId) {
|
|
36
|
+
const file = requirementsIndexFile(sessionId);
|
|
37
|
+
return fileExists(file) ? readJson(file) : [];
|
|
38
|
+
}
|
|
39
|
+
// Campfire requirement Name / EXT alias (EXT-15342 <-> REQ-15342).
|
|
40
|
+
const CAMPFIRE_NAME_RE = /^(REQ|EXT)-\d{1,10}$/;
|
|
41
|
+
// Markup out, entities decoded ONCE — the shared decoder, so this read agrees with what the
|
|
42
|
+
// campfire writer escaped. The sequential-replace form it replaced decoded twice: a stored
|
|
43
|
+
// `&lt;` came back as `<` rather than as the literal `<` the record actually holds.
|
|
44
|
+
function stripRichText(value) {
|
|
45
|
+
if (!value)
|
|
46
|
+
return "";
|
|
47
|
+
return decodeHtmlEntities(value
|
|
48
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
49
|
+
.replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
|
|
50
|
+
.replace(/<[^>]+>/g, ""))
|
|
51
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
export function composeCampfireProse(record) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
const title = stripRichText(record.Requirement_Name__c) || record.Name;
|
|
57
|
+
parts.push(`# ${title}`);
|
|
58
|
+
const persona = stripRichText(record.User_Story__c);
|
|
59
|
+
const action = stripRichText(record.User_Story_Action__c);
|
|
60
|
+
const suchThat = stripRichText(record.User_Story_Such_That__c);
|
|
61
|
+
if (persona || action || suchThat) {
|
|
62
|
+
parts.push(`As a ${persona || "user"}, I want to ${action || "(unspecified)"}${suchThat ? `, such that ${suchThat}` : ""}.`);
|
|
63
|
+
}
|
|
64
|
+
const description = stripRichText(record.User_Story_Description__c);
|
|
65
|
+
if (description)
|
|
66
|
+
parts.push(description);
|
|
67
|
+
// Solution design summary LEADS the content (the richest, now-primary design field).
|
|
68
|
+
// Requirement_Details__c is the legacy equivalent — normally only one is populated, but if a
|
|
69
|
+
// record carries both (older authoring), the design summary leads and details follow.
|
|
70
|
+
const designSummary = stripRichText(record.Solution_Design_Summary__c);
|
|
71
|
+
if (designSummary)
|
|
72
|
+
parts.push(`Solution design: ${designSummary}`);
|
|
73
|
+
const details = stripRichText(record.Requirement_Details__c);
|
|
74
|
+
if (details)
|
|
75
|
+
parts.push(`Details: ${details}`);
|
|
76
|
+
const given = stripRichText(record.Acceptance_Criteria_Given__c);
|
|
77
|
+
const when = stripRichText(record.Acceptance_Criteria_When__c);
|
|
78
|
+
const then = stripRichText(record.Acceptance_Criteria_Then__c);
|
|
79
|
+
if (given || when || then) {
|
|
80
|
+
parts.push(["Acceptance criteria:", given ? `- Given: ${given}` : "", when ? `- When: ${when}` : "", then ? `- Then: ${then}` : ""]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join("\n"));
|
|
83
|
+
}
|
|
84
|
+
const solution = stripRichText(record.Solution_Details__c);
|
|
85
|
+
if (solution)
|
|
86
|
+
parts.push(`Solution notes: ${solution}`);
|
|
87
|
+
// Defensive: empty on all current + Bruce-corpus records, but kept for older requirements.
|
|
88
|
+
const expected = stripRichText(record.Expected_Results__c);
|
|
89
|
+
if (expected)
|
|
90
|
+
parts.push(`Expected results: ${expected}`);
|
|
91
|
+
const prereq = stripRichText(record.Pre_Requisite__c);
|
|
92
|
+
if (prereq)
|
|
93
|
+
parts.push(`Pre-requisite: ${prereq}`);
|
|
94
|
+
return `${parts.join("\n\n")}\n`;
|
|
95
|
+
}
|
|
96
|
+
export const CAMPFIRE_FIELDS = [
|
|
97
|
+
"Id",
|
|
98
|
+
"Name",
|
|
99
|
+
"Requirement_Name__c",
|
|
100
|
+
"User_Story__c",
|
|
101
|
+
"User_Story_Action__c",
|
|
102
|
+
"User_Story_Such_That__c",
|
|
103
|
+
"User_Story_Description__c",
|
|
104
|
+
"Acceptance_Criteria_Given__c",
|
|
105
|
+
"Acceptance_Criteria_When__c",
|
|
106
|
+
"Acceptance_Criteria_Then__c",
|
|
107
|
+
"Solution_Design_Summary__c",
|
|
108
|
+
// Legacy content field (zDEPRECATED label, empty on recent records) — still authored by
|
|
109
|
+
// some older users, so we query it and include it when present.
|
|
110
|
+
"Requirement_Details__c",
|
|
111
|
+
"Solution_Details__c",
|
|
112
|
+
// Defensive tail — empty on all current + Bruce-corpus records (2026-07-24 audit), kept for
|
|
113
|
+
// older requirements.
|
|
114
|
+
"Expected_Results__c",
|
|
115
|
+
"Pre_Requisite__c"
|
|
116
|
+
];
|
|
117
|
+
// Shared by import_requirements and activate_session. Extracted rather than copied so a
|
|
118
|
+
// Campfire-started session and a locally-imported one produce byte-identical workspace state —
|
|
119
|
+
// two implementations of this would drift, and the drift would be invisible until a delivery
|
|
120
|
+
// behaved differently depending on how the session was started.
|
|
121
|
+
export async function importRequirementEntries(sessionId, session, entries) {
|
|
122
|
+
const index = await loadRequirementsIndex(sessionId);
|
|
123
|
+
const byExtId = new Map(index.map((entry) => [entry.extId, entry]));
|
|
124
|
+
const imported = [];
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const dir = requirementDir(sessionId, entry.extId);
|
|
127
|
+
const sourcePath = path.join(dir, "source", "requirement.md");
|
|
128
|
+
const requirementId = opaqueId("req", { extId: entry.extId, sessionId: session.sessionId });
|
|
129
|
+
const inputContentHash = semanticContentHash({ text: entry.text });
|
|
130
|
+
const requirementRevisionId = opaqueId("reqrev", {
|
|
131
|
+
extId: entry.extId,
|
|
132
|
+
contentHash: inputContentHash
|
|
133
|
+
});
|
|
134
|
+
await writeTextAtomic(sourcePath, entry.text);
|
|
135
|
+
const meta = {
|
|
136
|
+
extId: entry.extId,
|
|
137
|
+
title: entry.title,
|
|
138
|
+
requirementId,
|
|
139
|
+
requirementRevisionId,
|
|
140
|
+
inputContentHash,
|
|
141
|
+
...(entry.campfireId ? { campfireId: entry.campfireId } : {}),
|
|
142
|
+
importedAt: isoNow()
|
|
143
|
+
};
|
|
144
|
+
await writeJsonAtomic(path.join(dir, "source", "meta.json"), meta);
|
|
145
|
+
const indexEntry = {
|
|
146
|
+
extId: entry.extId,
|
|
147
|
+
title: entry.title,
|
|
148
|
+
requirementId,
|
|
149
|
+
requirementRevisionId,
|
|
150
|
+
inputContentHash,
|
|
151
|
+
sourcePath
|
|
152
|
+
};
|
|
153
|
+
byExtId.set(entry.extId, indexEntry);
|
|
154
|
+
imported.push(indexEntry);
|
|
155
|
+
}
|
|
156
|
+
const nextIndex = [...byExtId.values()].sort((a, b) => a.extId.localeCompare(b.extId));
|
|
157
|
+
await writeJsonAtomic(requirementsIndexFile(sessionId), nextIndex);
|
|
158
|
+
return imported;
|
|
159
|
+
}
|
|
160
|
+
export function registerSessionTools(server) {
|
|
161
|
+
server.registerTool("create_session", {
|
|
162
|
+
description: "Create (or idempotently reopen) a cc-mcp session workspace. Binds a sessionId, a requirement-batch revision id, and optionally a target org {alias, orgId}. Pure filesystem; touches no org.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
sessionId: z
|
|
165
|
+
.string()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Session id matching ^[A-Za-z][A-Za-z0-9_-]{5,127}$. Generated when omitted."),
|
|
168
|
+
targetOrgAlias: z.string().optional().describe("sf CLI alias for the target org (e.g. int-mock-org)."),
|
|
169
|
+
targetOrgId: z
|
|
170
|
+
.string()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe("15/18-char Salesforce Org ID. Defaults to T-Pinto when an alias is given without an id.")
|
|
173
|
+
}
|
|
174
|
+
}, async ({ sessionId, targetOrgAlias, targetOrgId }) => {
|
|
175
|
+
try {
|
|
176
|
+
const id = sessionId?.trim() ||
|
|
177
|
+
opaqueId("ses", { at: isoNow(), entropy: Math.random() });
|
|
178
|
+
const file = sessionFile(id);
|
|
179
|
+
if (fileExists(file)) {
|
|
180
|
+
const existing = await readJson(file);
|
|
181
|
+
if (targetOrgAlias && existing.targetOrg && existing.targetOrg.alias !== targetOrgAlias) {
|
|
182
|
+
return fail("SESSION_ORG_CONFLICT", `Session ${id} is already bound to org alias ${existing.targetOrg.alias}; refusing to rebind to ${targetOrgAlias}.`);
|
|
183
|
+
}
|
|
184
|
+
return ok(`session ${id} already exists (reopened)`, { session: existing });
|
|
185
|
+
}
|
|
186
|
+
const record = {
|
|
187
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
188
|
+
sessionId: id,
|
|
189
|
+
requirementBatchRevisionId: opaqueId("rbrev", { sessionId: id }),
|
|
190
|
+
targetOrg: targetOrgAlias
|
|
191
|
+
? { alias: targetOrgAlias, orgId: targetOrgId ?? "00Dfn00000BFjbBEAT" }
|
|
192
|
+
: null,
|
|
193
|
+
producer: await producerIdentity(),
|
|
194
|
+
createdAt: isoNow()
|
|
195
|
+
};
|
|
196
|
+
await ensureDir(sessionDir(id));
|
|
197
|
+
await writeJsonAtomic(file, record);
|
|
198
|
+
return ok(`session ${id} created\nworkspace: ${sessionDir(id)}\nbatchRevision: ${record.requirementBatchRevisionId}\ntargetOrg: ${record.targetOrg ? `${record.targetOrg.alias} (${record.targetOrg.orgId})` : "unbound"}`, { session: record });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof WorkspaceError)
|
|
202
|
+
return fail(error.code, error.message);
|
|
203
|
+
return internalError(error);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
server.registerTool("import_requirements", {
|
|
207
|
+
description: "Materialize requirement prose into the session workspace (source/requirement.md + meta.json per extId) and update the requirements index. Sources: inline text entries, a corpus directory of <extId>.md files, and/or a LIVE Campfire pull (read-only SOQL against the consultant org: composes prose led by the Solution Design Summary — the consultant's actual design, or the legacy Requirement Details for older records — plus the user-story + description, Given/When/Then, and solution notes; EXT-15342 maps to Campfire Name REQ-15342).",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
sessionId: z.string(),
|
|
210
|
+
requirements: z
|
|
211
|
+
.array(z.object({
|
|
212
|
+
extId: z.string().describe("External requirement id, e.g. EXT-9101."),
|
|
213
|
+
title: z.string(),
|
|
214
|
+
text: z.string().optional().describe("Requirement prose (inline)."),
|
|
215
|
+
path: z.string().optional().describe("Absolute path to a prose file (alternative to text).")
|
|
216
|
+
}))
|
|
217
|
+
.optional(),
|
|
218
|
+
corpusDir: z
|
|
219
|
+
.string()
|
|
220
|
+
.optional()
|
|
221
|
+
.describe("ESCAPE HATCH: directory of <extId>.md files to import wholesale. A genuine external path — requirements come from OUTSIDE the session, so unlike every other path input there is nothing to derive it from. Prefer `campfire` for a live pull."),
|
|
222
|
+
campfire: z
|
|
223
|
+
.object({
|
|
224
|
+
consultantOrg: z
|
|
225
|
+
.string()
|
|
226
|
+
.describe("sf CLI alias of the Campfire consultant org (e.g. vfppartial). Read-only."),
|
|
227
|
+
names: z
|
|
228
|
+
.array(z.string())
|
|
229
|
+
.nonempty()
|
|
230
|
+
.describe("Campfire requirement Names (REQ-15342) or EXT aliases (EXT-15342).")
|
|
231
|
+
})
|
|
232
|
+
.optional()
|
|
233
|
+
}
|
|
234
|
+
}, async ({ sessionId, requirements, corpusDir, campfire }) => {
|
|
235
|
+
try {
|
|
236
|
+
const session = await loadSession(sessionId);
|
|
237
|
+
const entries = [];
|
|
238
|
+
if (campfire) {
|
|
239
|
+
const reqNames = [];
|
|
240
|
+
for (const raw of campfire.names) {
|
|
241
|
+
const name = raw.trim();
|
|
242
|
+
if (!CAMPFIRE_NAME_RE.test(name)) {
|
|
243
|
+
return fail("INVALID_CAMPFIRE_NAME", `${JSON.stringify(raw)} is not REQ-#### or EXT-####.`);
|
|
244
|
+
}
|
|
245
|
+
reqNames.push(name.replace(/^EXT-/, "REQ-"));
|
|
246
|
+
}
|
|
247
|
+
const soql = `SELECT ${CAMPFIRE_FIELDS.join(", ")} FROM Campfire_Requirement__c WHERE Name IN (${[...new Set(reqNames)].map((name) => `'${name}'`).join(", ")}) ORDER BY Name`;
|
|
248
|
+
const run = await runProcess("sf", ["data", "query", "-q", soql, "--target-org", campfire.consultantOrg, "--json"], { env: sfCliEnv(), timeoutMs: 90_000 });
|
|
249
|
+
if (run.exitCode !== 0) {
|
|
250
|
+
return fail("CAMPFIRE_READ_FAILED", `Campfire SOQL exited ${run.exitCode}. Fail-closed — nothing imported.\n${run.stderr.slice(-400) || run.stdout.slice(-400)}`);
|
|
251
|
+
}
|
|
252
|
+
const parsed = JSON.parse(run.stdout);
|
|
253
|
+
const truncation = sfQueryTruncation(parsed);
|
|
254
|
+
if (truncation) {
|
|
255
|
+
// A capped read here would import a SUBSET of the named requirements while the
|
|
256
|
+
// missing-name check below vouched for completeness of whatever arrived. All-or-nothing.
|
|
257
|
+
return fail("CAMPFIRE_READ_TRUNCATED", `${truncation} Nothing imported (all-or-nothing).`);
|
|
258
|
+
}
|
|
259
|
+
const records = parsed.result?.records ?? [];
|
|
260
|
+
const foundNames = new Set(records.map((record) => record.Name));
|
|
261
|
+
const missing = reqNames.filter((name) => !foundNames.has(name));
|
|
262
|
+
if (missing.length > 0) {
|
|
263
|
+
return fail("CAMPFIRE_REQUIREMENTS_MISSING", `Not found in ${campfire.consultantOrg}: ${missing.join(", ")}. Nothing imported (all-or-nothing).`);
|
|
264
|
+
}
|
|
265
|
+
for (const record of records) {
|
|
266
|
+
entries.push({
|
|
267
|
+
extId: record.Name.replace(/^REQ-/, "EXT-"),
|
|
268
|
+
title: stripRichText(record.Requirement_Name__c) || record.Name,
|
|
269
|
+
text: composeCampfireProse(record),
|
|
270
|
+
campfireId: record.Id
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const requirement of requirements ?? []) {
|
|
275
|
+
let text = requirement.text;
|
|
276
|
+
if (!text && requirement.path) {
|
|
277
|
+
text = await fs.readFile(path.resolve(requirement.path), "utf8");
|
|
278
|
+
}
|
|
279
|
+
if (!text || !text.trim()) {
|
|
280
|
+
return fail("EMPTY_REQUIREMENT", `Requirement ${requirement.extId} has neither inline text nor a readable path.`);
|
|
281
|
+
}
|
|
282
|
+
entries.push({ extId: requirement.extId, title: requirement.title, text });
|
|
283
|
+
}
|
|
284
|
+
if (corpusDir) {
|
|
285
|
+
const dir = path.resolve(corpusDir);
|
|
286
|
+
const files = (await fs.readdir(dir)).filter((name) => name.endsWith(".md"));
|
|
287
|
+
for (const name of files) {
|
|
288
|
+
const text = await fs.readFile(path.join(dir, name), "utf8");
|
|
289
|
+
const extId = name.replace(/\.md$/, "");
|
|
290
|
+
entries.push({ extId, title: extId, text });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (entries.length === 0) {
|
|
294
|
+
return fail("NO_REQUIREMENTS", "Nothing to import: provide requirements[] and/or corpusDir.");
|
|
295
|
+
}
|
|
296
|
+
// Importing FROM Campfire is what proves which consultant org this session belongs to, so
|
|
297
|
+
// it is where the binding is recorded. Every later Campfire write then derives the org from
|
|
298
|
+
// the session instead of trusting an alias restated per call.
|
|
299
|
+
if (campfire && session.campfireOrg?.alias !== campfire.consultantOrg) {
|
|
300
|
+
session.campfireOrg = { alias: campfire.consultantOrg };
|
|
301
|
+
await writeJsonAtomic(sessionFile(sessionId), session);
|
|
302
|
+
}
|
|
303
|
+
const imported = await importRequirementEntries(sessionId, session, entries);
|
|
304
|
+
const lines = imported.map((entry) => `${entry.extId}\t${entry.requirementId}\t${entry.requirementRevisionId}`);
|
|
305
|
+
return ok(`imported ${imported.length} requirement(s) into session ${sessionId}\nextId\trequirementId\trequirementRevisionId\n${lines.join("\n")}`, { imported: imported, indexSize: (await loadRequirementsIndex(sessionId)).length });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (error instanceof WorkspaceError)
|
|
309
|
+
return fail(error.code, error.message);
|
|
310
|
+
return internalError(error);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// update_server — check, and apply.
|
|
2
|
+
//
|
|
3
|
+
// It exists at all because the consultant cannot fix a stale install themselves: they did not
|
|
4
|
+
// choose the version, they cannot see it, and the symptom of running an old one is a tool behaving
|
|
5
|
+
// subtly wrong rather than anything that announces itself. The supervisor is the only party in the
|
|
6
|
+
// loop who can notice and act, so it gets a tool rather than the operator getting instructions.
|
|
7
|
+
//
|
|
8
|
+
// It is ONE tool because "is there an update?" and "install it" are two halves of one decision the
|
|
9
|
+
// same caller makes seconds apart. What the old split bought — that asking can never install — is
|
|
10
|
+
// a property of the default: `apply` is off, so the read-only half is what you get by omission and
|
|
11
|
+
// the writing half has to be named.
|
|
12
|
+
//
|
|
13
|
+
// THE HOT SWAP IS GONE (2026-08-21). Applying installs via `npm install -g` and the running
|
|
14
|
+
// session keeps the build it loaded — the result says so plainly. What survives from the swap era
|
|
15
|
+
// is the timing doctrine: never apply mid-batch (an in-place install can hand a later lazy import
|
|
16
|
+
// a different build's module); activation and close are the safe edges, and activation already
|
|
17
|
+
// handles both automatically.
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { fail, ok } from "../core/result.js";
|
|
20
|
+
import { installedVersion } from "../core/install.js";
|
|
21
|
+
import { checkForUpdate, installUpdate } from "../core/update.js";
|
|
22
|
+
export function registerUpdateTools(server) {
|
|
23
|
+
server.registerTool("update_server", {
|
|
24
|
+
title: "Check for a newer IN2 server, and optionally install it",
|
|
25
|
+
description: "The whole self-update lifecycle. READ-ONLY BY DEFAULT: with `apply` omitted this only compares the running install against the version published on npm, changes nothing, and never throws — an offline machine reports updateAvailable:false with a reason. `apply: true` runs `npm install -g` for the published version. The install lands on disk for the NEXT conversation; this session keeps running the build it started with, and the result says so. It refuses on an install npm does not manage (a repo checkout updates with git, not npm). activate_session already checks, and close_batch installs a pending update after the evidence ships — so call this directly only to re-check mid-session, to repair an install with `force`, or when a result told you to.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
apply: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Install the published version with `npm install -g`. OFF by default — a check must never change what is installed."),
|
|
31
|
+
force: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Reinstall even when the published version is not newer. For recovering a damaged install. Only meaningful with apply:true.")
|
|
35
|
+
}
|
|
36
|
+
}, async ({ apply, force }) => {
|
|
37
|
+
const current = installedVersion();
|
|
38
|
+
if (!apply) {
|
|
39
|
+
const check = await checkForUpdate();
|
|
40
|
+
const lines = [`installed ${check.currentVersion}`];
|
|
41
|
+
if (check.latestVersion)
|
|
42
|
+
lines.push(`published ${check.latestVersion}`);
|
|
43
|
+
lines.push(check.updateAvailable
|
|
44
|
+
? check.managed
|
|
45
|
+
? "update available — call this tool again with { apply: true } to install it for the next conversation"
|
|
46
|
+
: "update available, but this install is not npm-managed (a checkout updates with git pull, not this tool)"
|
|
47
|
+
: "up to date");
|
|
48
|
+
if (check.reason)
|
|
49
|
+
lines.push(`note: ${check.reason}`);
|
|
50
|
+
return ok(lines.join("\n"), { ...check, applied: false });
|
|
51
|
+
}
|
|
52
|
+
const result = await installUpdate({ force: Boolean(force) });
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
if (result.failure === "not-managed") {
|
|
55
|
+
return fail("UPDATE_NOT_MANAGED", "This server is not running from an npm install, so `npm install -g` would not change what this machine runs. A repo checkout updates with git pull; anything else needs reinstalling by hand.");
|
|
56
|
+
}
|
|
57
|
+
return fail("UPDATE_FAILED", `${result.error ?? "unknown error"} — nothing was changed; this server is still on ${current}.`);
|
|
58
|
+
}
|
|
59
|
+
if (!result.updated) {
|
|
60
|
+
return ok(`already on ${current}; published version is ${result.toVersion}. Nothing to do.`, {
|
|
61
|
+
currentVersion: current,
|
|
62
|
+
latestVersion: result.toVersion,
|
|
63
|
+
updated: false,
|
|
64
|
+
applied: true
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return ok([
|
|
68
|
+
`installed ${result.toVersion} (was ${current})`,
|
|
69
|
+
// Stated every time because it is the one fact a reader will otherwise assume the other
|
|
70
|
+
// way: an install that succeeded did NOT change this conversation's tools.
|
|
71
|
+
"THIS conversation is still running the old build — an npm install cannot replace code a running process has loaded.",
|
|
72
|
+
"A NEW conversation starts on the new version. If you are mid-batch, finish it first; do not re-run this tool."
|
|
73
|
+
].join("\n"), {
|
|
74
|
+
currentVersion: current,
|
|
75
|
+
previousVersion: current,
|
|
76
|
+
latestVersion: result.toVersion,
|
|
77
|
+
updated: true,
|
|
78
|
+
applied: true,
|
|
79
|
+
restartRequired: true
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|