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.
Files changed (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. package/viewer/server.mjs +433 -0
@@ -0,0 +1,363 @@
1
+ // Self-update over npm: check the registry, and install with `npm install -g`.
2
+ //
3
+ // THE BESPOKE UPDATER IS GONE (2026-08-21, docs/plans/2026-08-07-017-npm-distribution-decision.md
4
+ // resolved as Option B). Four distinct live-caught bugs came out of the download/checksum/unpack/
5
+ // repoint/hot-swap machinery across two QA rounds; npm's installer is the one battle-tested piece
6
+ // of infrastructure every consultant machine already has, because Node ships it. What this module
7
+ // keeps is the part npm does not do: deciding WHEN an install is too old to run (the staleness
8
+ // gate, live-caught in QA round 1), and running the npm install from inside a session.
9
+ //
10
+ // WHAT REPLACING FILES UNDER A RUNNING PROCESS MEANS NOW. The old system installed each release
11
+ // into its own directory precisely so no file was ever replaced under an open process, then swapped
12
+ // the tool surface live. npm installs IN PLACE. A running server has its module graph loaded, but
13
+ // it lazy-imports several of its own dist files mid-run (crud.js, orgRepo.js, parcels.js, …) — an
14
+ // install that replaces dist under a session mid-batch can hand a later dynamic import a DIFFERENT
15
+ // build's module. So updates happen only at the EDGES of a run:
16
+ // - `stale` at activation → install, then STOP; the session was never going to proceed anyway.
17
+ // - `behind` at activation → noted, and close_batch installs it AFTER the evidence has shipped,
18
+ // when the process is about to exit and nothing will import anything again. The next session
19
+ // starts current without this one restarting — which preserves what the QA round 2 auto-apply
20
+ // was actually for (notices alone were read and ignored three times running).
21
+ // - `update_server {apply:true}` mid-session installs on request and SAYS the running session
22
+ // keeps its loaded build; a fresh conversation gets the new one.
23
+ import { existsSync } from "node:fs";
24
+ import path from "node:path";
25
+ import { execFile, spawn } from "node:child_process";
26
+ import { promisify } from "node:util";
27
+ import { PACKAGE_NAME, SERVER_ROOT, installedVersion, isNewer, isNpmInstall } from "./install.js";
28
+ import { redactSecrets } from "./betaFeedback.js";
29
+ const exec = promisify(execFile);
30
+ // Updater errors are the one string that routinely carries the consultant's machine into
31
+ // model-facing text: filesystem paths under their home directory (live-caught on the Windows QA
32
+ // runs). Sanitized HERE, at the module boundary, because every consumer — update_server's
33
+ // UPDATE_FAILED and activation's updateNotice — renders whatever this module hands them.
34
+ export function sanitizeUpdateError(text) {
35
+ return redactSecrets(text
36
+ // A path SEGMENT ends at the next separator, not at whitespace — "Jane Doe" is one segment,
37
+ // and stopping early on the space between the two names leaked the surname verbatim.
38
+ .replace(/([A-Za-z]):\\Users\\[^\\/'"]+/g, "$1:\\Users\\<user>")
39
+ .replace(/\/(?:Users|home)\/[^\\/'"]+/g, (m) => m.slice(0, m.lastIndexOf("/") + 1) + "<user>"));
40
+ }
41
+ /**
42
+ * HOW OLD A BUILD MAY BE BEFORE THE RUN STOPS BEING WORTH STARTING.
43
+ *
44
+ * LIVE-CAUGHT (QA round 1, 2026-08-06). Two testers ran batches on builds two days stale. One hit
45
+ * `ORG_NOT_BOUND` at the Campfire writeback — step 8 of 9 — from a bug fixed ~21 hours after the
46
+ * build they were on was cut; the other's run produced no phase telemetry at all. Both saw an
47
+ * `UPDATE AVAILABLE` line at activation. Both ran anyway. A notice that competes with fourteen
48
+ * other lines of activation output is not a gate, and the cost of ignoring it was a wasted
49
+ * multi-hour run and a Salesforce org left half-configured.
50
+ *
51
+ * Days rather than version distance: releases are cut several times a day, so "12 releases behind"
52
+ * is noise, while "your build predates two days of fixes" is the thing that correlates with hitting
53
+ * an already-fixed bug.
54
+ *
55
+ * Override with `CC_MCP_STALE_AFTER_DAYS`. `0` disables the block entirely — for developing against
56
+ * an intentionally pinned build, which is the one legitimate reason to run knowingly old code.
57
+ */
58
+ export const DEFAULT_STALE_AFTER_DAYS = 2;
59
+ export function staleAfterDays(env = process.env) {
60
+ const raw = (env.CC_MCP_STALE_AFTER_DAYS ?? "").trim();
61
+ if (!raw)
62
+ return DEFAULT_STALE_AFTER_DAYS;
63
+ const parsed = Number(raw);
64
+ // A typo must not silently disable the gate — fall back to the default rather than to 0.
65
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_STALE_AFTER_DAYS;
66
+ }
67
+ /**
68
+ * The UTC instant a `0.YYYYMMDD.HHMMSS` version names, or null for anything else.
69
+ *
70
+ * A version is derived from its merge commit's UTC timestamp (see AGENTS.md), so the string IS the
71
+ * build date and no extra lookup is needed. The third component is HHMMSS **as a number** — semver
72
+ * forbids leading zeros, so `02:05:55` publishes as `.20555`, not `.020555` — and it is re-padded
73
+ * to six digits here before parsing. `0.0.0` (an unreadable package.json) and ordinary semver
74
+ * versions parse to null, which callers must treat as "cannot tell" rather than "not stale".
75
+ */
76
+ export function versionStamp(version) {
77
+ const match = /^\d+\.(\d{4})(\d{2})(\d{2})\.(\d{1,6})$/.exec(version.trim());
78
+ if (!match)
79
+ return null;
80
+ const [, y, mo, d, hms] = match;
81
+ const padded = hms.padStart(6, "0");
82
+ const [h, mi, s] = [padded.slice(0, 2), padded.slice(2, 4), padded.slice(4, 6)];
83
+ const at = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
84
+ return Number.isFinite(at) ? new Date(at) : null;
85
+ }
86
+ /**
87
+ * Whole days the installed build is behind the published one. Null when either version is not a
88
+ * release stamp — a checkout reports no age rather than a fabricated one.
89
+ */
90
+ export function daysBehind(current, latest) {
91
+ const [from, to] = [versionStamp(current), versionStamp(latest)];
92
+ if (!from || !to)
93
+ return null;
94
+ return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86_400_000));
95
+ }
96
+ export function classifyStaleness(check, thresholdDays = staleAfterDays()) {
97
+ if (!check.updateAvailable || !check.latestVersion)
98
+ return { staleness: "current" };
99
+ const behind = daysBehind(check.currentVersion, check.latestVersion);
100
+ if (behind === null)
101
+ return { staleness: "behind" };
102
+ // `0` disables the block; without this guard it would classify every update as stale.
103
+ const stale = thresholdDays > 0 && behind >= thresholdDays;
104
+ return { staleness: stale ? "stale" : "behind", behindDays: behind };
105
+ }
106
+ export function updateActionFor(check) {
107
+ if (!check.updateAvailable)
108
+ return "none";
109
+ if (!check.managed)
110
+ return "update-by-hand";
111
+ return check.staleness === "stale" ? "stop-and-update" : "update-at-close";
112
+ }
113
+ const REGISTRY = "https://registry.npmjs.org";
114
+ /**
115
+ * The latest published version, from the npm registry's abbreviated metadata.
116
+ *
117
+ * `CC_MCP_REGISTRY` overrides the host (a mirror, or a test double) — never a launch-service URL:
118
+ * distribution and activation are separate concerns now, and only activation still needs
119
+ * CC_MCP_LAUNCH_URL.
120
+ */
121
+ export async function fetchLatestVersion(registry) {
122
+ const base = (registry ?? process.env.CC_MCP_REGISTRY ?? REGISTRY).trim().replace(/\/$/, "");
123
+ const res = await fetch(`${base}/${PACKAGE_NAME}`, {
124
+ // The abbreviated document is ~50x smaller than the full packument and carries dist-tags.
125
+ headers: { accept: "application/vnd.npm.install-v1+json" },
126
+ signal: AbortSignal.timeout(20_000)
127
+ });
128
+ if (!res.ok)
129
+ throw new Error(`registry check failed: HTTP ${res.status}`);
130
+ const body = (await res.json());
131
+ const latest = body["dist-tags"]?.latest;
132
+ if (!latest)
133
+ throw new Error("registry response carries no dist-tags.latest");
134
+ return latest;
135
+ }
136
+ // Never throws. A version check is a convenience run on the way to doing real work, and an offline
137
+ // laptop or a registry having a bad day must not turn into a failed activation.
138
+ export async function checkForUpdate(registry) {
139
+ const currentVersion = installedVersion();
140
+ const managed = isNpmInstall();
141
+ try {
142
+ const latestVersion = await fetchLatestVersion(registry);
143
+ let reason;
144
+ if (!managed) {
145
+ const raw = `not an npm install (running from ${SERVER_ROOT})`;
146
+ console.error(raw);
147
+ reason = sanitizeUpdateError(raw);
148
+ }
149
+ const base = {
150
+ currentVersion,
151
+ latestVersion,
152
+ updateAvailable: isNewer(latestVersion, currentVersion),
153
+ managed,
154
+ ...(reason ? { reason } : {})
155
+ };
156
+ return { ...base, ...classifyStaleness(base) };
157
+ }
158
+ catch (error) {
159
+ return {
160
+ currentVersion,
161
+ updateAvailable: false,
162
+ managed,
163
+ // An unreachable registry says nothing about how old this build is, and guessing "stale"
164
+ // would block every offline run. Unknown is `current` by construction here.
165
+ staleness: "current",
166
+ reason: error instanceof Error ? error.message : String(error)
167
+ };
168
+ }
169
+ }
170
+ /**
171
+ * How to run npm — the npm that belongs to THIS node, wherever it is.
172
+ *
173
+ * `npm` cannot be assumed on PATH: MCP servers spawn with whatever minimal environment the client
174
+ * provides, and a PATH that resolves the right `node` (this process exists, so it did) still may
175
+ * not carry npm's shim directory. npm itself, though, ships inside every Node distribution at a
176
+ * fixed spot relative to the node binary — so the binary this process IS running is the one
177
+ * reliable anchor. PATH `npm` is the fallback, not the default.
178
+ *
179
+ * Exported for the doctor; the shape mirrors sfInvocation in spawn.ts for the same Windows reason
180
+ * (a .cmd shim cannot be spawned directly — CVE-2024-27980).
181
+ */
182
+ export function npmInvocation(args) {
183
+ const nodeDir = path.dirname(process.execPath);
184
+ const candidates = process.platform === "win32"
185
+ ? [path.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")]
186
+ : [
187
+ path.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
188
+ path.join(nodeDir, "..", "node_modules", "npm", "bin", "npm-cli.js")
189
+ ];
190
+ for (const cli of candidates) {
191
+ if (existsSync(cli))
192
+ return { command: process.execPath, args: [cli, ...args] };
193
+ }
194
+ // No npm beside the binary (a standalone node, or an unusual layout) — fall back to PATH. On
195
+ // Windows that must go through cmd.exe because the PATH entry is npm.cmd.
196
+ if (process.platform === "win32") {
197
+ return { command: "cmd.exe", args: ["/d", "/s", "/c", "npm", ...args] };
198
+ }
199
+ return { command: "npm", args };
200
+ }
201
+ /**
202
+ * The npm global prefix THIS install lives under, derived from where this module is.
203
+ *
204
+ * Derived and passed explicitly (`npm install -g --prefix …`) rather than left to npm's own
205
+ * resolution, because the two can disagree: the bootstrap installs under a private prefix
206
+ * (~/.in2-agent/npm-global) precisely so no machine ever needs sudo, while the npm on PATH — or
207
+ * beside a different node — defaults to the system prefix. An update that resolves the prefix
208
+ * itself would install a second copy somewhere else and leave the registered one stale, which
209
+ * reads as "the update said it worked and nothing changed".
210
+ *
211
+ * Layouts: <prefix>/lib/node_modules/<pkg> (posix) and <prefix>/node_modules/<pkg> (Windows).
212
+ */
213
+ export function installPrefix() {
214
+ const parts = SERVER_ROOT.split(path.sep);
215
+ const idx = parts.lastIndexOf("node_modules");
216
+ if (idx < 0)
217
+ return undefined;
218
+ const above = parts.slice(0, idx);
219
+ if (above[above.length - 1] === "lib")
220
+ above.pop();
221
+ return above.join(path.sep) || undefined;
222
+ }
223
+ /**
224
+ * Start the install and DON'T wait for it — for the one caller that must not block: activation.
225
+ *
226
+ * The stale path installs at activation, but an awaited `npm install -g` can run for minutes on a
227
+ * cold cache, and the activation code was already redeemed (single-use) before the check ran — a
228
+ * client-side tool timeout mid-install reads as a failed activation whose retry is impossible.
229
+ * So the stale path fires npm detached and returns immediately: the session stops either way, the
230
+ * operator restarts, and if the install has not landed yet the next activation's gate simply fires
231
+ * again and retries. Self-healing by construction; nothing depends on this attempt succeeding.
232
+ *
233
+ * The registry read IS awaited (20 s cap): without a resolved version there is nothing to install,
234
+ * and its failure is worth reporting synchronously as the old imperative.
235
+ */
236
+ export async function startDetachedInstall() {
237
+ if (!isNpmInstall()) {
238
+ const raw = `not an npm install (running from ${SERVER_ROOT})`;
239
+ console.error(raw);
240
+ return { ok: false, failure: "not-managed", error: sanitizeUpdateError(raw) };
241
+ }
242
+ try {
243
+ const latest = await fetchLatestVersion();
244
+ const prefix = installPrefix();
245
+ const { command, args } = npmInvocation([
246
+ "install",
247
+ "-g",
248
+ `${PACKAGE_NAME}@${latest}`,
249
+ ...(prefix ? [`--prefix=${prefix}`] : []),
250
+ "--ignore-scripts",
251
+ "--no-audit",
252
+ "--no-fund"
253
+ ]);
254
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
255
+ child.unref();
256
+ return { ok: true, toVersion: latest };
257
+ }
258
+ catch (error) {
259
+ const message = error instanceof Error ? error.message : String(error);
260
+ console.error(message);
261
+ return { ok: false, failure: "failed", error: sanitizeUpdateError(message) };
262
+ }
263
+ }
264
+ /**
265
+ * A `behind` build noticed at activation, remembered until close.
266
+ *
267
+ * Installing mid-run is unsafe (in-place npm install vs. this process's lazy imports — module
268
+ * header), and QA rounds 1-2 proved a notice asking anyone to act later is a notice ignored. So
269
+ * activation records the intent and `close_batch` performs it after the evidence has shipped: the
270
+ * last tool call of the run, after which the process only exits. The next session starts current
271
+ * without this one restarting — the same outcome the hot-swap auto-apply bought, one session later.
272
+ *
273
+ * In-process state, deliberately: a pending update that outlives the process is just "an update is
274
+ * available", which the next activation's check rediscovers on its own.
275
+ */
276
+ let pendingAtClose = false;
277
+ export function scheduleUpdateAtClose() {
278
+ pendingAtClose = true;
279
+ }
280
+ /** Install the update activation deferred, if any. Called by close_batch; never throws. */
281
+ export async function installPendingUpdate() {
282
+ if (!pendingAtClose)
283
+ return undefined;
284
+ pendingAtClose = false;
285
+ const result = await installUpdate();
286
+ if (result.ok && result.updated)
287
+ installedUnderThisProcess = result.toVersion;
288
+ return result;
289
+ }
290
+ /**
291
+ * The version an at-close install landed UNDER this still-running process, if any.
292
+ *
293
+ * Exists for the second-activation-same-chat case: close_batch installed the new build in place,
294
+ * the process lived on, and the operator pastes another code into the same conversation (the
295
+ * successor-batch pattern makes that legitimate). That session would run build-X callers against
296
+ * build-Y lazy imports — the exact mixed-build state the update timing exists to prevent — so
297
+ * activation checks this first and stops with a restart instead of starting the loop.
298
+ */
299
+ let installedUnderThisProcess;
300
+ export function updateInstalledUnderThisProcess() {
301
+ return installedUnderThisProcess;
302
+ }
303
+ /**
304
+ * Install the published release with `npm install -g`. Never throws.
305
+ *
306
+ * The RUNNING process keeps its loaded build regardless of what this writes to disk — Node's
307
+ * module cache holds what was imported, and nothing here can (or tries to) refresh it. Callers own
308
+ * the timing: this must only run when the session will not lazily import anything else that
309
+ * matters, which in practice means "at activation before a stop" or "at close_batch after the
310
+ * evidence has shipped". See the module header for why mid-run is unsafe.
311
+ *
312
+ * `force` reinstalls even when the published version is not newer — the documented repair path for
313
+ * a damaged install (`npm install -g pkg@latest` re-extracts unconditionally when told a version).
314
+ */
315
+ export async function installUpdate(options = {}) {
316
+ const fromVersion = installedVersion();
317
+ const base = { ok: false, fromVersion, updated: false };
318
+ if (!isNpmInstall()) {
319
+ const raw = `not an npm install (running from ${SERVER_ROOT})`;
320
+ console.error(raw);
321
+ return { ...base, failure: "not-managed", error: sanitizeUpdateError(raw) };
322
+ }
323
+ try {
324
+ const latest = await fetchLatestVersion(options.registry);
325
+ if (!options.force && !isNewer(latest, fromVersion)) {
326
+ return { ...base, ok: true, toVersion: latest };
327
+ }
328
+ const spec = `${PACKAGE_NAME}@${latest}`;
329
+ const prefix = installPrefix();
330
+ const { command, args } = npmInvocation([
331
+ "install",
332
+ "-g",
333
+ spec,
334
+ // Into THIS install's prefix, explicitly — npm's own default can name a different one, and
335
+ // then the update lands as a second copy while the registered server stays stale.
336
+ ...(prefix ? [`--prefix=${prefix}`] : []),
337
+ // No scripts: the package needs none to install (skills/agents copy on first run instead),
338
+ // and an update path that executes arbitrary lifecycle hooks is a bigger surface than the
339
+ // convenience is worth. --no-audit/--no-fund for the same reason the old installer used
340
+ // them: an update must not stall on advisory network calls.
341
+ "--ignore-scripts",
342
+ "--no-audit",
343
+ "--no-fund",
344
+ ...(options.registry ? [`--registry=${options.registry}`] : [])
345
+ ]);
346
+ await exec(command, args, { timeout: options.timeoutMs ?? 600_000 });
347
+ return { ...base, ok: true, updated: true, toVersion: latest };
348
+ }
349
+ catch (error) {
350
+ const message = error instanceof Error ? error.message : String(error);
351
+ console.error(message);
352
+ // "Nothing was changed" is a claim, so verify it. npm installs IN PLACE, and an npm killed
353
+ // mid-extract (the 10-minute timeout, a dropped connection) can leave the package directory
354
+ // half-replaced — at which point "the next activation will retry" is false, because the next
355
+ // conversation has no server to activate with. The one file whose absence proves that is the
356
+ // entry the registration points at.
357
+ const entryIntact = existsSync(path.join(SERVER_ROOT, "dist", "index.js"));
358
+ const detail = entryIntact
359
+ ? sanitizeUpdateError(message)
360
+ : `${sanitizeUpdateError(message)} — AND the install directory is now incomplete (dist/index.js is missing). The next conversation may fail to start; repair from a terminal with: npm install -g ${PACKAGE_NAME}@latest`;
361
+ return { ...base, failure: "failed", error: detail };
362
+ }
363
+ }
@@ -0,0 +1,117 @@
1
+ // The read-only session viewer: start it if it isn't up, reuse it if it is.
2
+ //
3
+ // The viewer (`<install>/viewer/server.mjs`) is a dependency-free Node HTTP server that projects one
4
+ // session directory into a localhost UI. It writes nothing and talks to nothing but the filesystem,
5
+ // which is what makes this helper's whole failure policy "shrug": a viewer that never started costs
6
+ // the operator a browser tab, never a delivery. Every path below therefore returns a WARN outcome
7
+ // rather than throwing, and the caller appends a line either way.
8
+ //
9
+ // LIFECYCLE IS DELIBERATELY ABSENT. Nothing kills the viewer — not close_batch, not process exit.
10
+ // It is read-only, so leaving it up showing the final report is the useful end state, and the next
11
+ // activation's healthz probe adopts it. There is no PID file and no manager layer on purpose: the
12
+ // port IS the registry.
13
+ import { spawn as spawnChild } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { APP_ROOT, WORKSPACE_ROOT } from "./paths.js";
17
+ /**
18
+ * Where the viewer lives.
19
+ *
20
+ * A plain join rather than the `existsSync ? shipped : repo` pattern SKILLS_DIR and the playbook
21
+ * need: the build artifact stages `viewer/` at the stage root and the repo keeps it at
22
+ * `apps/cc-mcp-server/viewer/`, and APP_ROOT resolves to each of those respectively — so the same
23
+ * relative path is correct in both, with nothing to disambiguate.
24
+ */
25
+ export const VIEWER_SCRIPT = path.join(APP_ROOT, "viewer", "server.mjs");
26
+ /** Must agree with `viewer/server.mjs`, which reads the same variable and defaults the same way. */
27
+ export function viewerPort() {
28
+ const raw = Number(process.env.IN2_VIEWER_PORT);
29
+ return Number.isInteger(raw) && raw > 0 && raw < 65_536 ? raw : 4199;
30
+ }
31
+ export function viewerBaseUrl(port = viewerPort()) {
32
+ return `http://localhost:${port}`;
33
+ }
34
+ const PROBE_TIMEOUT_MS = 500;
35
+ const POLL_BUDGET_MS = 2_000;
36
+ const POLL_INTERVAL_MS = 150;
37
+ async function defaultProbe(url, timeoutMs) {
38
+ try {
39
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
40
+ return res.ok;
41
+ }
42
+ catch {
43
+ // Connection refused, DNS, timeout — all the same answer here: nothing is serving.
44
+ return false;
45
+ }
46
+ }
47
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
+ /**
49
+ * Ensure a viewer is serving, and say which of the three things happened.
50
+ *
51
+ * Order matters: PROBE FIRST. Two sessions in a row, or two Claude Code windows, must not each
52
+ * spawn a server onto the same port — the second would die EADDRINUSE and the operator would be
53
+ * told a viewer started that isn't theirs. An answering healthz means someone else's process is
54
+ * already serving the same read-only projection, which is exactly what we want.
55
+ */
56
+ export async function ensureViewer(deps = {}) {
57
+ const port = viewerPort();
58
+ const url = viewerBaseUrl(port);
59
+ const health = `${url}/healthz`;
60
+ const probe = deps.probe ?? defaultProbe;
61
+ const sleep = deps.sleep ?? defaultSleep;
62
+ const exists = deps.exists ?? existsSync;
63
+ const spawn = deps.spawn ?? spawnChild;
64
+ const script = deps.script ?? VIEWER_SCRIPT;
65
+ try {
66
+ if (await probe(health, PROBE_TIMEOUT_MS))
67
+ return { status: "reused", url, port };
68
+ // A repo checkout without a built artifact, or a partial install. Degrade rather than throw —
69
+ // this is a cosmetic convenience on the most important call of the session.
70
+ if (!exists(script)) {
71
+ return { status: "warn", url, port, detail: `no viewer at ${script}` };
72
+ }
73
+ // stdio "ignore" IS LOAD-BEARING, not tidiness. This server speaks MCP over its OWN stdio: a
74
+ // child that inherited the handles could write a startup banner straight into the JSON-RPC
75
+ // stream and desync the client mid-session. `detached` + `unref` then let the viewer outlive
76
+ // this process, which is the point — the operator keeps reading the final report after the
77
+ // conversation ends. Both flags behave the same way on Windows, where an inherited handle
78
+ // additionally keeps the console alive after the parent exits.
79
+ const child = spawn(process.execPath, [script, deps.sessionsRoot ?? WORKSPACE_ROOT], {
80
+ detached: true,
81
+ stdio: "ignore",
82
+ env: process.env
83
+ });
84
+ // A detached child that fails to launch emits 'error' ASYNCHRONOUSLY, and an 'error' event with
85
+ // no listener is an unhandled exception that takes the whole MCP server down. The poll below is
86
+ // the real verdict, so the event itself is deliberately swallowed.
87
+ child.on("error", () => undefined);
88
+ child.unref();
89
+ // The child binds its port before it can answer, so a single immediate probe would always say
90
+ // dead. Poll to a hard cap: activation must not sit here waiting on a browser convenience.
91
+ const budgetMs = deps.pollBudgetMs ?? POLL_BUDGET_MS;
92
+ const deadline = Date.now() + budgetMs;
93
+ while (Date.now() < deadline) {
94
+ await sleep(POLL_INTERVAL_MS);
95
+ if (await probe(health, PROBE_TIMEOUT_MS))
96
+ return { status: "started", url, port };
97
+ }
98
+ return { status: "warn", url, port, detail: `spawned but ${health} did not answer within ${budgetMs}ms` };
99
+ }
100
+ catch (error) {
101
+ return { status: "warn", url, port, detail: error instanceof Error ? error.message : String(error) };
102
+ }
103
+ }
104
+ /**
105
+ * The activation lines. The session id is PINNED into the URL: the viewer defaults to the
106
+ * newest-mtime session, so two concurrent deliveries sharing one viewer would otherwise both point
107
+ * at whichever ran last.
108
+ */
109
+ export function viewerNotice(outcome, sessionId) {
110
+ if (outcome.status === "warn") {
111
+ return [
112
+ "",
113
+ `WARN: the live progress viewer did not start (${outcome.detail ?? "unknown"}). This changes nothing about the delivery — the viewer is read-only. Do not retry it or mention it again.`
114
+ ];
115
+ }
116
+ return ["", `Live progress: ${outcome.url}/?session=${encodeURIComponent(sessionId)}`];
117
+ }
@@ -0,0 +1,92 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { WORKSPACE_ROOT } from "./paths.js";
5
+ import { OPAQUE_ID_RE } from "./ids.js";
6
+ export class WorkspaceError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.code = code;
11
+ this.name = "WorkspaceError";
12
+ }
13
+ }
14
+ export function assertWorkspaceId(kind, value) {
15
+ if (!OPAQUE_ID_RE.test(value)) {
16
+ throw new WorkspaceError("INVALID_ID", `${kind} must match ${OPAQUE_ID_RE} (got ${JSON.stringify(value)}).`);
17
+ }
18
+ }
19
+ export function sessionDir(sessionId) {
20
+ assertWorkspaceId("sessionId", sessionId);
21
+ return path.join(WORKSPACE_ROOT, sessionId);
22
+ }
23
+ export function sessionFile(sessionId) {
24
+ return path.join(sessionDir(sessionId), "session.json");
25
+ }
26
+ export function requirementDir(sessionId, extId) {
27
+ assertWorkspaceId("extId", extId);
28
+ return path.join(sessionDir(sessionId), "requirements", extId);
29
+ }
30
+ export function analysisCurrentFile(sessionId, extId) {
31
+ return path.join(requirementDir(sessionId, extId), "analysis", "current.json");
32
+ }
33
+ export function analysisCheckpointFile(sessionId, extId, revision) {
34
+ return path.join(requirementDir(sessionId, extId), "analysis", "checkpoints", `${String(revision).padStart(4, "0")}.json`);
35
+ }
36
+ export function planDir(sessionId) {
37
+ return path.join(sessionDir(sessionId), "plan");
38
+ }
39
+ /**
40
+ * One file per question round put to the operator (`0001.json`, `0002.json`, …).
41
+ *
42
+ * Lives here rather than in `tools/operator.ts`, which wrote it and owned the path: the round files
43
+ * are now read by `core/operatorQa.ts` to ship what was asked and answered, and `core` must not
44
+ * import from `tools` — the dependency runs the other way everywhere else.
45
+ */
46
+ export function questionsDir(sessionId) {
47
+ return path.join(sessionDir(sessionId), "questions");
48
+ }
49
+ /**
50
+ * The append-only record of which chapter of the run this session is in (`core/phases.ts`).
51
+ *
52
+ * AT THE TOP OF THE SESSION DIRECTORY, beside `session.json`, rather than under a subdirectory:
53
+ * every other artifact here is produced BY a phase, and this one is about the phases themselves.
54
+ * A fresh session resuming a dead run reads this file first and the artifacts second.
55
+ */
56
+ export function phaseLedgerFile(sessionId) {
57
+ return path.join(sessionDir(sessionId), "phases.jsonl");
58
+ }
59
+ export function dispositionsFile(sessionId) {
60
+ return path.join(sessionDir(sessionId), "dispositions", "dispositions.json");
61
+ }
62
+ export function writebackDir(sessionId) {
63
+ return path.join(sessionDir(sessionId), "writeback");
64
+ }
65
+ export async function ensureDir(dir) {
66
+ await fs.mkdir(dir, { recursive: true });
67
+ }
68
+ export async function writeJsonAtomic(file, value) {
69
+ await ensureDir(path.dirname(file));
70
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
71
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
72
+ await fs.rename(tmp, file);
73
+ }
74
+ export async function writeTextAtomic(file, text) {
75
+ await ensureDir(path.dirname(file));
76
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
77
+ await fs.writeFile(tmp, text, "utf8");
78
+ await fs.rename(tmp, file);
79
+ }
80
+ export async function readJson(file) {
81
+ const raw = await fs.readFile(file, "utf8");
82
+ return JSON.parse(raw);
83
+ }
84
+ export function fileExists(file) {
85
+ return existsSync(file);
86
+ }
87
+ export async function listDirs(dir) {
88
+ if (!existsSync(dir))
89
+ return [];
90
+ const entries = await fs.readdir(dir, { withFileTypes: true });
91
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
92
+ }
@@ -0,0 +1,93 @@
1
+ // Activation-scoped write authority.
2
+ //
3
+ // The problem this solves: direct-tier write authority came only from CC_MCP_DIRECT_ALLOWLIST, an
4
+ // env var fixed when the MCP server is spawned. An org's id is not knowable until someone has
5
+ // logged into it, so a customer could never be pre-listed — which is why internal testing reached
6
+ // for the `*` wildcard. That wildcard makes every authenticated org writable, and shipping it to a
7
+ // consultant who also has their production org in the sf CLI is the single largest foreseeable
8
+ // blast radius in this system.
9
+ //
10
+ // A grant is the alternative: activate_session authenticates exactly one org, VERIFIES that the org
11
+ // the CLI landed on matches the one the launch service named (refusing otherwise), and records a
12
+ // grant for that org id. Write authority then means "the org this install was activated against"
13
+ // rather than "whatever the operator's machine happens to be logged into".
14
+ //
15
+ // Why a file rather than session state: direct_apply takes targetOrg, not sessionId, so the gate has
16
+ // no session to consult. Threading a session id through every write tool would put the authority
17
+ // decision in the model's hands — it could pass a different session than the one that granted
18
+ // access. A grant keyed by org id, read straight from disk by the gate, cannot be argued with.
19
+ import path from "node:path";
20
+ import { existsSync } from "node:fs";
21
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
22
+ import { WORKSPACE_ROOT } from "./paths.js";
23
+ export const GRANTS_SCHEMA_VERSION = "direct-write-grants-v1";
24
+ // A grant outlives a single session on purpose — a consultant reasonably continues work in a new
25
+ // conversation without re-clicking through Campfire. It does NOT outlive the engagement: an
26
+ // abandoned laptop should not stay authorized against a client org indefinitely.
27
+ const DEFAULT_GRANT_TTL_DAYS = 30;
28
+ export function grantsFilePath() {
29
+ return path.join(WORKSPACE_ROOT, "..", "direct-write-grants.json");
30
+ }
31
+ function normalize(orgId) {
32
+ return orgId.slice(0, 15);
33
+ }
34
+ function grantTtlDays() {
35
+ const raw = Number(process.env.CC_MCP_GRANT_TTL_DAYS);
36
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_GRANT_TTL_DAYS;
37
+ }
38
+ async function readGrantsFile() {
39
+ const file = grantsFilePath();
40
+ if (!existsSync(file))
41
+ return { schemaVersion: GRANTS_SCHEMA_VERSION, grants: [] };
42
+ try {
43
+ const parsed = JSON.parse(await readFile(file, "utf8"));
44
+ return { schemaVersion: GRANTS_SCHEMA_VERSION, grants: Array.isArray(parsed.grants) ? parsed.grants : [] };
45
+ }
46
+ catch {
47
+ // A corrupt grants file must not become an accidental grant, and must not wedge the server
48
+ // either. Treat it as "no grants" — the operator re-activates, which is a recoverable state.
49
+ return { schemaVersion: GRANTS_SCHEMA_VERSION, grants: [] };
50
+ }
51
+ }
52
+ /** Non-expired grants, newest first. */
53
+ export async function activeGrants(now = new Date()) {
54
+ const { grants } = await readGrantsFile();
55
+ return grants
56
+ .filter((g) => {
57
+ const expiry = Date.parse(g.expiresAt);
58
+ // An unparseable expiry is treated as expired, never as forever.
59
+ return Number.isFinite(expiry) && expiry > now.getTime();
60
+ })
61
+ .sort((a, b) => b.grantedAt.localeCompare(a.grantedAt));
62
+ }
63
+ export async function isOrgGranted(orgId, now = new Date()) {
64
+ const target = normalize(orgId);
65
+ return (await activeGrants(now)).some((g) => normalize(g.orgId) === target);
66
+ }
67
+ /**
68
+ * Record write authority for exactly one org. Called by activate_session AFTER it has confirmed the
69
+ * CLI authenticated the org the launch service named — never on the operator's say-so alone.
70
+ */
71
+ export async function recordDirectWriteGrant(input, now = new Date()) {
72
+ const grant = {
73
+ orgId: normalize(input.orgId),
74
+ alias: input.alias,
75
+ grantedAt: now.toISOString(),
76
+ expiresAt: new Date(now.getTime() + grantTtlDays() * 86_400_000).toISOString(),
77
+ source: input.source,
78
+ ...(input.username ? { username: input.username } : {})
79
+ };
80
+ const { grants } = await readGrantsFile();
81
+ // Re-activating the same org refreshes rather than accumulates; other orgs are preserved so a
82
+ // consultant working across two clients does not lose one by activating the other.
83
+ const next = [grant, ...grants.filter((g) => normalize(g.orgId) !== grant.orgId)];
84
+ const file = grantsFilePath();
85
+ await mkdir(path.dirname(file), { recursive: true });
86
+ const tmp = `${file}.tmp`;
87
+ await writeFile(tmp, JSON.stringify({ schemaVersion: GRANTS_SCHEMA_VERSION, grants: next }, null, 2), {
88
+ encoding: "utf8",
89
+ mode: 0o600
90
+ });
91
+ await rename(tmp, file);
92
+ return grant;
93
+ }