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,214 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { readCustomFieldSnapshot, readGenericMetadataSnapshot, readLayoutSnapshot } from "./bridge.js";
4
+ import { resolveScope } from "./scope.js";
5
+ import { collectPrecedentComponents, probeOrgTraits } from "./precedent.js";
6
+ import { checkScopeNameAmbiguity } from "./nameAmbiguity.js";
7
+ import { renderMap } from "./mapmd.js";
8
+ function safeName(value) {
9
+ return value.replace(/[^A-Za-z0-9_.-]/g, "_");
10
+ }
11
+ async function writeSnapshot(outDir, relFile, snap) {
12
+ const abs = path.join(outDir, relFile);
13
+ await fs.mkdir(path.dirname(abs), { recursive: true });
14
+ await fs.writeFile(abs, `${JSON.stringify(snap, null, 2)}\n`, "utf8");
15
+ }
16
+ // A projection that resolves to hundreds of components is almost never what was intended; it is
17
+ // what happens when a standard object gets added to scope "to be safe". Account and Contact carry
18
+ // hundreds of custom fields on any real org, and each one is a component. The cost is invisible at
19
+ // the call site — the scope reads as five objects — which is exactly why this refuses rather than
20
+ // warns. Batching made the pathological case survivable; it did not make it correct.
21
+ export class ScopeTooLargeError extends Error {
22
+ total;
23
+ limit;
24
+ breakdown;
25
+ constructor(total, limit, breakdown) {
26
+ super(`scope resolves to ${total} components (limit ${limit})`);
27
+ this.total = total;
28
+ this.limit = limit;
29
+ this.breakdown = breakdown;
30
+ }
31
+ }
32
+ export const DEFAULT_MAX_COMPONENTS = 250;
33
+ export async function runProjection(reader, opts) {
34
+ const readField = opts.readField ?? readCustomFieldSnapshot;
35
+ const readGeneric = opts.readGeneric ?? readGenericMetadataSnapshot;
36
+ const readLayout = opts.readLayout ?? readLayoutSnapshot;
37
+ // A projection is a point-in-time read, so it starts from nothing cached. This belongs HERE and
38
+ // not at the one call site that noticed the problem: any caller reusing a reader across two
39
+ // projections would otherwise get the first one's answers for the second one's question, and
40
+ // direct_apply — which reads before and after a deploy through one reader — is simply the case
41
+ // where that is most obviously wrong.
42
+ reader.invalidate?.();
43
+ const identity = await reader.identity();
44
+ const resolved = await resolveScope(reader, opts.scope);
45
+ const limit = opts.maxComponents ?? DEFAULT_MAX_COMPONENTS;
46
+ const total = resolved.components.length +
47
+ resolved.objects.reduce((n, p) => n + 1 + p.customFields.length + p.validationRules.length, 0);
48
+ if (total > limit) {
49
+ throw new ScopeTooLargeError(total, limit, resolved.objects
50
+ .map((p) => ({ objectApiName: p.objectApiName, customFields: p.customFields.length }))
51
+ .sort((a, b) => b.customFields - a.customFields));
52
+ }
53
+ // AMBIGUITY, checked against the CALLER'S components and before precedent adds any of its own.
54
+ // A projection reads the name it was given and records the answer; when two components in the org
55
+ // answer to that name, the choice was made silently and the manifest is where it has to surface.
56
+ const scopeAmbiguities = await checkScopeNameAmbiguity(reader, resolved.components).catch(() => []);
57
+ // PRECEDENT + ORG TRAITS, gathered AFTER the caller's scope has been size-checked so they can
58
+ // neither inflate the operator's cap nor trip ScopeTooLargeError on work they did not ask for.
59
+ // Both are best-effort: a failure here leaves the projection exactly as it would have been.
60
+ const traits = await probeOrgTraits(reader).catch(() => ({ multiCurrency: false, personAccounts: false, callCenter: false, notes: ["org-trait probe failed"] }));
61
+ // Concrete evidence beats the probe: CurrencyIsoCode exists as a standard field ONLY on a
62
+ // multi-currency org, and the scope resolution has already enumerated standard fields. A probe
63
+ // that says "no" beside a field list that says otherwise is how CF79 shipped a MAP whose trait
64
+ // line contradicted its own page — and the trait exists precisely to stop an authored Layout
65
+ // from dropping CurrencyIsoCode.
66
+ if (!traits.multiCurrency && resolved.objects.some((o) => o.standardFields.some((f) => f.name === "CurrencyIsoCode"))) {
67
+ traits.multiCurrency = true;
68
+ traits.notes.push("multi-currency: probe said no, but CurrencyIsoCode exists on a projected object — field evidence wins");
69
+ }
70
+ const precedent = opts.skipPrecedent
71
+ ? []
72
+ : await collectPrecedentComponents(reader, resolved.components).catch(() => []);
73
+ // MARKED, not just appended. Downstream these are indistinguishable from the caller's own scope
74
+ // unless the manifest says so, and at least one consumer must never confuse the two: the org
75
+ // mirror records what a wave DELIVERED, and it reads the wave's post manifest to find out. A
76
+ // sample that reads as a delivery becomes a claim about somebody's work — see
77
+ // ManifestComponent.precedent for the live case.
78
+ const precedentKeys = new Set(precedent.map((c) => `${c.typeName}:${c.fullName}`));
79
+ resolved.components.push(...precedent);
80
+ // Warm every read this run is about to perform, batched ten-per-call and five calls in flight,
81
+ // BEFORE the sequential walk below. The walk stays sequential on purpose — it writes files and
82
+ // appends to `components`, so its ordering is what makes the manifest deterministic — but by the
83
+ // time it runs, each read is a cache hit rather than a SOAP round-trip.
84
+ //
85
+ // This is the whole fix for a projection scoped to standard objects. Account + Contact resolve to
86
+ // ~490 custom fields; one round-trip each ran past five minutes with no output and read as a
87
+ // hang. Batched, that is ~49 calls.
88
+ await reader.prefetch?.([
89
+ { typeName: "CustomObject", fullNames: resolved.objects.map((p) => p.objectApiName) },
90
+ {
91
+ typeName: "CustomField",
92
+ fullNames: [
93
+ ...resolved.objects.flatMap((p) => p.customFields.map((f) => `${p.objectApiName}.${f}`)),
94
+ ...resolved.components.filter((c) => c.typeName === "CustomField").map((c) => c.fullName)
95
+ ]
96
+ },
97
+ {
98
+ typeName: "ValidationRule",
99
+ fullNames: resolved.objects.flatMap((p) => p.validationRules.map((r) => `${p.objectApiName}.${r}`))
100
+ },
101
+ ...Object.entries(resolved.components
102
+ .filter((c) => c.typeName !== "CustomField")
103
+ .reduce((acc, c) => {
104
+ (acc[c.typeName] ??= []).push(c.fullName);
105
+ return acc;
106
+ }, {})).map(([typeName, fullNames]) => ({ typeName, fullNames }))
107
+ ]);
108
+ const components = [];
109
+ const byFile = new Map();
110
+ // Scoped to THIS run for the same reason the reader's cache is (see reader.invalidate above): a
111
+ // projection is a point-in-time read, and layouts that come and go between runs must not be
112
+ // answered from a previous one.
113
+ const layoutRows = new Map();
114
+ const record = async (typeName, fullName, relFile, snap) => {
115
+ await writeSnapshot(opts.outDir, relFile, snap);
116
+ byFile.set(relFile, snap);
117
+ const found = snap.state.metadata !== null;
118
+ components.push({
119
+ typeName,
120
+ fullName,
121
+ file: relFile,
122
+ found,
123
+ ...(precedentKeys.has(`${typeName}:${fullName}`) ? { precedent: true } : {}),
124
+ // Fail-closed visibility: a read error is never rendered as clean absence.
125
+ ...(snap.errors?.length ? { errors: snap.errors } : found ? {} : { errors: ["not found"] })
126
+ });
127
+ };
128
+ for (const plan of resolved.objects) {
129
+ const objDir = path.join("objects", safeName(plan.objectApiName));
130
+ const objSnap = (await readGeneric(reader, "customObject", "CustomObject", plan.objectApiName));
131
+ await record("CustomObject", plan.objectApiName, path.join(objDir, "object.json"), objSnap);
132
+ for (const field of plan.customFields) {
133
+ const fullName = `${plan.objectApiName}.${field}`;
134
+ const snap = (await readField(reader, fullName));
135
+ await record("CustomField", fullName, path.join(objDir, "fields", `${safeName(field)}.json`), snap);
136
+ }
137
+ for (const rule of plan.validationRules) {
138
+ const fullName = `${plan.objectApiName}.${rule}`;
139
+ const snap = (await readGeneric(reader, "validationRule", "ValidationRule", fullName));
140
+ await record("ValidationRule", fullName, path.join(objDir, "validation-rules", `${safeName(rule)}.json`), snap);
141
+ }
142
+ }
143
+ for (const component of resolved.components) {
144
+ const relFile = path.join("components", safeName(component.typeName), `${safeName(component.fullName)}.json`);
145
+ try {
146
+ // Layout gets a DEDICATED reader for the same reason CustomField does: the generic
147
+ // metadata.read is not a faithful source for one of its facets. See orgread/layoutSnapshot.ts
148
+ // — SOAP readMetadata silently omits related lists and jsforce renders the absence as `[]`,
149
+ // which on a full-file-replace type reads as a licence to delete them.
150
+ const snap = component.typeName === "CustomField"
151
+ ? (await readField(reader, component.fullName))
152
+ : component.typeName === "Layout"
153
+ ? (await readLayout(reader, component.fullName, layoutRows))
154
+ : (await readGeneric(reader, "generic", component.typeName, component.fullName));
155
+ await record(component.typeName, component.fullName, relFile, snap);
156
+ }
157
+ catch (error) {
158
+ // ONE unreadable component must not abort the whole projection. Live-caught: an org listed
159
+ // ApprovalProcess:SCMC__Purchase_Order__c.POApprove whose parent object no longer exists —
160
+ // orphaned metadata left behind by an uninstalled managed package. Salesforce does not return
161
+ // found:false for that, it throws a SOAP fault ("Entity 'null' not found"), which killed a
162
+ // three-component projection over one dead component. Any mature org accumulates these.
163
+ //
164
+ // Recorded as a per-component error instead, which is how every other read failure in this
165
+ // file already behaves — the component reads as absent WITH a reason, and the manifest's
166
+ // error count reflects it. Still fail-closed: absent-with-an-error is never mistaken for
167
+ // verified-absent, because callers check errors before treating a miss as truth.
168
+ await record(component.typeName, component.fullName, relFile, {
169
+ family: "generic",
170
+ typeName: component.typeName,
171
+ fullName: component.fullName,
172
+ observedAt: new Date().toISOString(),
173
+ source: "metadata.read",
174
+ state: { metadata: null },
175
+ errors: [
176
+ `read failed: ${error instanceof Error ? error.message : String(error)} — the component is listed by the org but could not be read. A common cause is orphaned metadata whose parent object no longer exists.`
177
+ ]
178
+ });
179
+ }
180
+ }
181
+ const manifest = {
182
+ schemaVersion: "org-projection-v1",
183
+ orgAlias: opts.orgAlias,
184
+ orgId: identity.orgId,
185
+ apiVersion: opts.apiVersion,
186
+ label: opts.label,
187
+ createdAt: new Date().toISOString(),
188
+ scope: opts.scope,
189
+ reader: "cc-mcp orgread: SalesforceOrgReader + snapshot readers (ported from sf-config-verifier)",
190
+ components,
191
+ objects: resolved.objects.map((plan) => ({
192
+ objectApiName: plan.objectApiName,
193
+ standardFields: plan.standardFields,
194
+ layouts: plan.layouts,
195
+ errors: plan.errors
196
+ })),
197
+ enumerationErrors: resolved.errors,
198
+ orgTraits: traits,
199
+ // Omitted when empty on purpose — see ProjectionManifest.scopeAmbiguities. A best-effort check
200
+ // that reported `[]` would be claiming it looked and found nothing, which it cannot promise.
201
+ ...(scopeAmbiguities.length ? { scopeAmbiguities } : {}),
202
+ counts: {
203
+ components: components.length,
204
+ errors: components.filter((c) => c.errors?.length).length +
205
+ resolved.errors.length +
206
+ resolved.objects.reduce((n, o) => n + o.errors.length, 0)
207
+ }
208
+ };
209
+ const mapMarkdown = renderMap(manifest, byFile);
210
+ await fs.mkdir(opts.outDir, { recursive: true });
211
+ await fs.writeFile(path.join(opts.outDir, "projection.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
212
+ await fs.writeFile(path.join(opts.outDir, "MAP.md"), mapMarkdown, "utf8");
213
+ return { manifest, mapMarkdown, byFile };
214
+ }
@@ -0,0 +1,110 @@
1
+ function soqlQuote(value) {
2
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3
+ }
4
+ // Live-caught (T-Pinto, 2026-07-22): Tooling-API EntityDefinition does NOT honor \_
5
+ // escaping in LIKE — the escaped pattern matches nothing. So the query deliberately
6
+ // OVER-matches (wildcards left live) and the exact prefix is enforced client-side,
7
+ // which also drops the platform-derived entities (__Share/__ChangeEvent/...) the
8
+ // wildcard sweeps in.
9
+ const PROJECTABLE_OBJECT_RE = /__(c|mdt)$/;
10
+ const CUSTOM_FIELD_RE = /__(c|pc)$/i;
11
+ export function scopeIsEmpty(scope) {
12
+ return ((scope.objects?.length ?? 0) === 0 &&
13
+ !(scope.prefix && scope.prefix.trim()) &&
14
+ (scope.components?.length ?? 0) === 0);
15
+ }
16
+ async function planObject(reader, objectApiName) {
17
+ const plan = {
18
+ objectApiName,
19
+ customFields: [],
20
+ standardFields: [],
21
+ validationRules: [],
22
+ layouts: [],
23
+ errors: []
24
+ };
25
+ const quoted = soqlQuote(objectApiName);
26
+ try {
27
+ const fields = await reader.toolingQuery(`SELECT QualifiedApiName, DataType FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '${quoted}'`);
28
+ for (const f of fields.records) {
29
+ if (CUSTOM_FIELD_RE.test(f.QualifiedApiName))
30
+ plan.customFields.push(f.QualifiedApiName);
31
+ else
32
+ plan.standardFields.push({ name: f.QualifiedApiName, dataType: f.DataType });
33
+ }
34
+ plan.customFields.sort();
35
+ plan.standardFields.sort((a, b) => a.name.localeCompare(b.name));
36
+ }
37
+ catch (error) {
38
+ plan.errors.push(`field enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ try {
41
+ const rules = await reader.toolingQuery(`SELECT ValidationName FROM ValidationRule WHERE EntityDefinition.QualifiedApiName = '${quoted}'`);
42
+ plan.validationRules = rules.records.map((r) => r.ValidationName).sort();
43
+ }
44
+ catch (error) {
45
+ plan.errors.push(`validation-rule enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ try {
48
+ // Layout.TableEnumOrId holds the object NAME for standard objects and the object ID
49
+ // for custom objects — resolve the DurableId for the custom case.
50
+ let tableKey = objectApiName;
51
+ if (/__c$/i.test(objectApiName)) {
52
+ const entity = await reader.toolingQuery(`SELECT DurableId FROM EntityDefinition WHERE QualifiedApiName = '${quoted}'`);
53
+ const durable = entity.records[0]?.DurableId;
54
+ if (!durable)
55
+ throw new Error(`EntityDefinition DurableId not found for ${objectApiName}`);
56
+ tableKey = durable;
57
+ }
58
+ const layouts = await reader.toolingQuery(`SELECT Name FROM Layout WHERE TableEnumOrId = '${soqlQuote(tableKey)}'`);
59
+ plan.layouts = layouts.records.map((l) => l.Name).sort();
60
+ }
61
+ catch (error) {
62
+ plan.errors.push(`layout enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ return plan;
65
+ }
66
+ export async function resolveScope(reader, scope) {
67
+ const errors = [];
68
+ const objectNames = new Set(scope.objects ?? []);
69
+ if (scope.prefix && scope.prefix.trim()) {
70
+ const prefix = scope.prefix.trim();
71
+ try {
72
+ const rows = await reader.toolingQuery(`SELECT QualifiedApiName FROM EntityDefinition WHERE QualifiedApiName LIKE '${soqlQuote(prefix)}%'`);
73
+ const matched = rows.records
74
+ .map((r) => r.QualifiedApiName)
75
+ .filter((name) => name.startsWith(prefix) && PROJECTABLE_OBJECT_RE.test(name));
76
+ for (const name of matched)
77
+ objectNames.add(name);
78
+ if (matched.length === 0) {
79
+ errors.push(`prefix '${prefix}' matched no projectable objects (__c/__mdt)`);
80
+ }
81
+ }
82
+ catch (error) {
83
+ errors.push(`prefix enumeration failed: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ }
86
+ const objects = [];
87
+ for (const name of [...objectNames].sort()) {
88
+ objects.push(await planObject(reader, name));
89
+ }
90
+ // Explicit components, deduped against object expansion output (by type+fullName).
91
+ const covered = new Set();
92
+ for (const plan of objects) {
93
+ covered.add(`CustomObject:${plan.objectApiName}`);
94
+ for (const f of plan.customFields)
95
+ covered.add(`CustomField:${plan.objectApiName}.${f}`);
96
+ for (const r of plan.validationRules)
97
+ covered.add(`ValidationRule:${plan.objectApiName}.${r}`);
98
+ }
99
+ const components = [];
100
+ const seen = new Set();
101
+ for (const c of scope.components ?? []) {
102
+ const key = `${c.typeName}:${c.fullName}`;
103
+ if (covered.has(key) || seen.has(key))
104
+ continue;
105
+ seen.add(key);
106
+ components.push({ typeName: c.typeName, fullName: c.fullName });
107
+ }
108
+ components.sort((a, b) => `${a.typeName}:${a.fullName}`.localeCompare(`${b.typeName}:${b.fullName}`));
109
+ return { objects, components, errors };
110
+ }
@@ -0,0 +1,5 @@
1
+ // Org projection (plan 2026-07-22-001, Phase 1). Structural types only — the live
2
+ // implementations come from the verifier (the independent read path), imported via
3
+ // the ambient declarations in verifier.d.ts so the projection shows the org exactly
4
+ // as the gate sees it. No third read path.
5
+ export {};
@@ -0,0 +1,322 @@
1
+ // resolve_blocker — close an answered blocker without re-running an analyzer.
2
+ //
3
+ // WHAT USED TO LIVE HERE, AND WHY IT DOES NOT
4
+ //
5
+ // analyzer_briefs returned one ready-to-launch prompt per requirement. It solved a real problem —
6
+ // the supervisor had been inlining a 7 KB skill into every prompt — but it solved it by moving the
7
+ // same large text one hop: the tool emitted the prompts and the supervisor echoed them back
8
+ // verbatim into its Agent calls, so the whole thing crossed the wire twice. Six calls across four
9
+ // runs, and the file it pointed at was already on disk at a fixed path the whole time.
10
+ //
11
+ // The subagent call is now just: read <skill path>, you own REQ-X, plus the two or three sentences
12
+ // of cross-requirement context that were ever actually dynamic. No tool needed to say that.
13
+ //
14
+ // ORIGINAL NOTE, kept because the constraint it records still holds
15
+ //
16
+ // Step 3 of the loop is "one analyzer subagent per requirement", and the harness told the
17
+ // supervisor to compose each prompt itself, carrying the FULL plan-requirement SKILL.md inline plus
18
+ // the paths plus the batch context. That is ~7.4 KB of skill text per requirement, authored N times,
19
+ // before a single analyzer starts. On a 15-requirement batch it is the largest block of writing in
20
+ // the whole run, it is nearly identical every time, and every variation in it is a way for one
21
+ // analyzer to be told something subtly different from its siblings.
22
+ //
23
+ // It also could not work on a real install: the skill lives in the repo, and until now nothing
24
+ // shipped it in the download. A consultant's machine had no SKILL.md to inline — the same failure
25
+ // class as check_components pointing at an unbuilt workspace package.
26
+ //
27
+ // So the server emits ready-to-launch briefs. It already knows the session, the requirements, the
28
+ // session directory layout and the projection dirs; the only thing it does not know is the
29
+ // cross-requirement context the supervisor formed by reading the batch, which is exactly what the
30
+ // `context` parameter is for. The brief POINTS at the skill rather than inlining it, so each
31
+ // analyzer reads one file from disk instead of receiving a copy through the prompt.
32
+ //
33
+ // ADDRESSING UNDER THE REVISION MODEL
34
+ //
35
+ // `promote` and `drop` used to address entries by `typeName:fullName` and act on every match. That
36
+ // was total while a key meant exactly one entry. Once a batch can change a component twice — a
37
+ // `create` in the requirement that brings it into being, an `update` in the one that adjusts it,
38
+ // each bound to its own authored file — the same bare key names TWO entries, and the old loop
39
+ // would have dropped the create along with the update, silently, in the file Configuration Item
40
+ // ownership is derived from.
41
+ //
42
+ // So an operation may name the revision as `{key, path}`, matched against the entry's
43
+ // `authoredFile`, and a bare key on a multi-entry component REFUSES (`AMBIGUOUS_COMPONENT`) rather
44
+ // than acting on both. A bare key on a single-entry component is the whole address, exactly as
45
+ // before — which is every plan written before the revision model and most entries after it.
46
+ //
47
+ // The read-modify-write also stopped being an untyped `Record` round trip: it goes through
48
+ // `core/planFile.ts`, which returns the parsed document unchanged where nothing edited it. That
49
+ // module was built for this call — its mutators take an ENTRY, not a key, for the reason above.
50
+ import path from "node:path";
51
+ import { promises as fs } from "node:fs";
52
+ import { z } from "zod";
53
+ import { clearProvisional, readPlanFile, removeEntry, serializePlanFile } from "../core/planFile.js";
54
+ import { fail, internalError, ok } from "../core/result.js";
55
+ import { WorkspaceError, sessionDir } from "../core/workspace.js";
56
+ /**
57
+ * One `promote`/`drop` target.
58
+ *
59
+ * The bare-string form is kept because it is the right address for a single-entry key and because
60
+ * every existing caller (and the playbook's own `promote: ["<key>"]`) writes it. The object form
61
+ * exists only for the case the string form cannot express: which of a component's revisions.
62
+ *
63
+ * A FACTORY, not a shared constant: `promote` and `drop` both use it, and zod-to-json-schema
64
+ * collapses a reused instance into a `$ref` at the second use site (`#/properties/promote/items`).
65
+ * Legal JSON Schema, but a pointer is a worse thing to hand a model than the shape itself, and
66
+ * strict tool-schema consumers do not all resolve internal refs. Two calls, two inlined copies.
67
+ */
68
+ const operationSchema = () => z.union([
69
+ z.string().describe("Component key, e.g. CustomField:X__c.Days__c."),
70
+ z.object({
71
+ key: z.string().describe("Component key, e.g. CustomField:X__c.Days__c."),
72
+ path: z
73
+ .string()
74
+ .optional()
75
+ .describe("WHICH revision, for a component this batch changes more than once. Matched against the entry's `authoredFile` (relative to requirements/<extId>/authored/). Only needed when the key has more than one entry — a bare key then refuses rather than hitting both."),
76
+ intent: z
77
+ .enum(["create", "update", "delete"])
78
+ .optional()
79
+ .describe("WHICH revision, by what it does. This is how you name a `delete` entry, which has no authoredFile for `path` to match — use it when the operator holds back a deletion that rides beside a state this requirement also deploys.")
80
+ })
81
+ ]);
82
+ const opKey = (op) => (typeof op === "string" ? op : op.key);
83
+ const opPath = (op) => (typeof op === "string" ? undefined : op.path);
84
+ const opIntent = (op) => (typeof op === "string" ? undefined : op.intent);
85
+ /** Separator-normalized comparison, matching how the wave assembler matches its own `path`. */
86
+ const normRel = (value) => value.split(/[\\/]/).filter(Boolean).join("/");
87
+ const opLabel = (op) => {
88
+ const bound = opPath(op);
89
+ const intent = opIntent(op);
90
+ const parts = [bound ? normRel(bound) : undefined, intent ? `intent: ${intent}` : undefined].filter(Boolean);
91
+ return parts.length > 0 ? `${opKey(op)} (${parts.join(", ")})` : opKey(op);
92
+ };
93
+ /**
94
+ * Address one operation at one entry.
95
+ *
96
+ * Matches over ALL entries for the key, including `deployable: false` routes: the hazard this
97
+ * guards is "one call changed two entries", and a route entry is still an entry someone can mean.
98
+ */
99
+ function resolveOperation(plan, verb, op) {
100
+ const key = opKey(op);
101
+ const bound = opPath(op);
102
+ const forKey = plan.entries.filter((e) => e.key === key);
103
+ if (forKey.length === 0)
104
+ return { kind: "unknown", verb, op };
105
+ const intent = opIntent(op);
106
+ // BOTH discriminators, and both are needed. `path` tells two deployed states apart, and it is the
107
+ // only thing that can — but a `delete` entry has no authoredFile by construction, so `path` can
108
+ // never name one. Without `intent`, a requirement that both changes and retires its own component
109
+ // would carry a deletion nobody could drop: the bare key refuses as ambiguous and no path matches.
110
+ // Dropping a held-back deletion is exactly the operation the live plan prescribed for itself.
111
+ let matches = forKey;
112
+ if (bound !== undefined)
113
+ matches = matches.filter((e) => e.authoredFile !== null && normRel(e.authoredFile) === normRel(bound));
114
+ if (intent !== undefined)
115
+ matches = matches.filter((e) => e.intent === intent);
116
+ if (matches.length === 0)
117
+ return { kind: "unknown", verb, op };
118
+ if (matches.length > 1)
119
+ return { kind: "ambiguous", verb, op, entries: matches };
120
+ return { kind: "hit", verb, op, entry: matches[0] };
121
+ }
122
+ /** `intent=create authoredFile=…` — enough to tell two revisions of one component apart. */
123
+ const describeEntry = (entry) => `entry ${entry.index}: intent=${entry.intent ?? "(undeclared)"} authoredFile=${entry.authoredFile ?? "(none)"}`;
124
+ export function registerAnalyzeTools(server) {
125
+ server.registerTool("resolve_blocker", {
126
+ title: "Record an operator answer against an analyzer's blocker",
127
+ description: "Close a blocker in requirements/<extId>/plan.json with the operator's answer, WITHOUT re-running the analyzer. Use when the answer changes a detail you can edit yourself — a picklist default, an element value, a label. " +
128
+ "EDIT THE ANALYZER'S FILE under requirements/<extId>/authored/, never the assembled wave tree: direct_lint/direct_apply rebuild that tree on every call, so an edit made inside it is silently overwritten rather than deployed. Nothing checks this. " +
129
+ "Re-run the analyzer instead when the answer changes WHICH components exist or who owns them — that is a different plan, not an edit. " +
130
+ "A component can also be provisional on ANOTHER requirement's blocker: omit blockerId and pass `reason` naming that decision, and promote/drop reconcile THIS plan without inventing a local blocker. Refuses on `REASON_REQUIRED` / `NOTHING_TO_RECONCILE`. " +
131
+ "ADDRESSING: a component this batch changes twice has two entries under one key, so name the revision as {key, path} on promote/drop; a bare key refuses (`AMBIGUOUS_COMPONENT`). A DELETE entry has no authoredFile — name it {key, intent: 'delete'}, which is how a decision to HOLD a deletion is recorded before the Campfire writeback reports it as work.",
132
+ inputSchema: {
133
+ sessionId: z.string().describe("The session the plan belongs to."),
134
+ extId: z.string().describe("Requirement whose blocker this answers, e.g. REQ-15462."),
135
+ blockerId: z
136
+ .string()
137
+ .optional()
138
+ .describe("Blocker id from plan.json, e.g. B1. OMIT when the gating decision lives on another requirement's blocker — then `reason` is required and promote/drop reconcile this plan directly."),
139
+ answer: z.string().optional().describe("The operator's answer, verbatim. Recorded so the plan states what was decided, not merely that something was. Required with blockerId."),
140
+ reason: z
141
+ .string()
142
+ .optional()
143
+ .describe("Required when blockerId is omitted: which decision settles this reconciliation, e.g. \"REQ-15519 B1 resolved: OWD Private — the condition this component was provisional on\". Recorded on the plan as a reconciliation entry."),
144
+ amended: z
145
+ .array(z.string())
146
+ .optional()
147
+ .describe("Component keys you edited as a result, e.g. [\"CustomField:CF75_Intake_Request__c.CF75_Days_Remaining__c\"]. Recorded on the plan so the change is attributable."),
148
+ promote: z
149
+ .array(operationSchema())
150
+ .optional()
151
+ .describe("Components whose PROVISIONAL authored file the answer confirms — a key, or {key, path} to name ONE revision. Clears `provisional`, which is what wave assembly gates on (`COMPONENT_STILL_PROVISIONAL`)."),
152
+ drop: z
153
+ .array(operationSchema())
154
+ .optional()
155
+ .describe("Components the answer ELIMINATED — a key, or {key, path} to remove ONE revision and leave the other. Removes the entry from plan.json entirely, so plan_waves stops ordering a wave that assembles nothing for it.")
156
+ }
157
+ }, async ({ sessionId, extId, blockerId, answer, reason, amended, promote, drop }) => {
158
+ try {
159
+ const file = path.join(sessionDir(sessionId), "requirements", extId, "plan.json");
160
+ // One reader for this schema (`core/planFile.ts`), and it hands back the document it parsed
161
+ // so the write half round-trips: fields this tool has never heard of survive, and a plan
162
+ // nothing edited is rewritten byte-for-byte. Structural refusals come back as DATA and are
163
+ // deliberately not fatal here — a plan whose bindings are wrong is exactly the plan the
164
+ // operator uses this tool to FIX, while the assembler and plan_waves refuse on the finding.
165
+ const read = await readPlanFile(file, extId);
166
+ if (!read.ok)
167
+ return fail(read.error.code, read.error.message);
168
+ const plan = read.plan;
169
+ const blockers = Array.isArray(plan.document.blockers) ? plan.document.blockers : [];
170
+ let blocker;
171
+ if (blockerId !== undefined) {
172
+ if (!answer)
173
+ return fail("ANSWER_REQUIRED", `Resolving ${extId} blocker ${blockerId} needs the operator's answer verbatim.`);
174
+ blocker = blockers.find((b) => String(b.id) === blockerId);
175
+ // Fail-closed: silently accepting an unknown id would report a question as settled while it
176
+ // is still open, and the approval card is built from exactly this record.
177
+ if (!blocker) {
178
+ return fail("BLOCKER_NOT_FOUND", `${extId} has no blocker "${blockerId}". Open blockers: ${blockers.map((b) => String(b.id)).join(", ") || "(none)"}.`);
179
+ }
180
+ if (blocker.resolved) {
181
+ return fail("BLOCKER_ALREADY_RESOLVED", `${extId} blocker ${blockerId} is already answered: "${String(blocker.answer ?? "")}". Recording a second answer would lose the first; correct the plan by hand if it is wrong.`);
182
+ }
183
+ }
184
+ else {
185
+ // No local blocker: this is a reconciliation driven by a decision recorded ELSEWHERE
186
+ // (typically another requirement's blocker — live: CF79's sharing rule was provisional
187
+ // on the object owner's OWD answer, and the only way to promote it was hand-editing
188
+ // plan.json). Fail-closed on a missing reason or an empty reconciliation: a promote with
189
+ // no recorded cause is exactly the unattributable hand-edit this tool exists to replace.
190
+ if (!reason) {
191
+ return fail("REASON_REQUIRED", `Without a blockerId, pass \`reason\` naming the decision that settles this reconciliation (e.g. the other requirement's resolved blocker).`);
192
+ }
193
+ if (!promote?.length && !drop?.length) {
194
+ return fail("NOTHING_TO_RECONCILE", `Without a blockerId, pass promote and/or drop — a reason alone changes nothing.`);
195
+ }
196
+ }
197
+ // RECONCILE THE PLAN, not just the blocker. An answer routinely settles which components
198
+ // exist and which authored draft is the right one, and until now the only way to record
199
+ // that was to hand-edit plan.json — the file Configuration Item ownership is derived from.
200
+ // Editing it by hand is how plans drift, and a stale entry propagates: plan_waves orders a
201
+ // component nobody will author, and the wave then assembles nothing for it.
202
+ //
203
+ // RESOLVE EVERY OPERATION BEFORE CHANGING ANYTHING. Both refusals below promise "Nothing
204
+ // was changed"; addressing first is what makes that true of the in-memory document as well
205
+ // as the file, so a half-applied reconcile cannot exist even transiently.
206
+ const componentCount = () => (Array.isArray(plan.document.components) ? plan.document.components.length : 0);
207
+ const before = componentCount();
208
+ const resolved = [
209
+ ...(drop ?? []).map((op) => resolveOperation(plan, "drop", op)),
210
+ ...(promote ?? []).map((op) => resolveOperation(plan, "promote", op))
211
+ ];
212
+ // The addressing context of THIS call, fixed before any drop changes it: which keys carry
213
+ // more than one entry, and therefore which reports have to name a file to mean anything.
214
+ const multiEntryKeys = new Set(plan.entries.filter((e, _i, all) => all.filter((o) => o.key === e.key).length > 1).map((e) => e.key));
215
+ // On a multi-entry key the key alone no longer says WHICH revision this touched. The file is
216
+ // the usual discriminator; a delete has none, so it is named by what it does — the report has
217
+ // to be readable as a record of "the deletion was dropped", not "something with no file was".
218
+ const entryLabel = (entry) => multiEntryKeys.has(entry.key)
219
+ ? `${entry.key} (${entry.authoredFile ?? (entry.intent === "delete" ? "intent: delete, no authoredFile" : "no authoredFile")})`
220
+ : entry.key;
221
+ // Checked BEFORE the unknown-key refusal: a bare key on two revisions is the more specific
222
+ // finding, and its remediation is the one the caller cannot guess.
223
+ const ambiguous = resolved.filter((r) => r.kind === "ambiguous");
224
+ if (ambiguous.length > 0) {
225
+ const first = ambiguous[0];
226
+ const example = first.entries.find((e) => e.authoredFile !== null) ?? first.entries[0];
227
+ return fail("AMBIGUOUS_COMPONENT", [
228
+ `${extId}'s plan.json holds more than one entry — two states of one component — for a component named by key alone.`,
229
+ "Acting on the key would hit EVERY revision of it: a drop would delete the create as well as the update.",
230
+ ...ambiguous.flatMap((a) => [
231
+ ` ${a.verb} ${opKey(a.op)} matches ${a.entries.length} entries:`,
232
+ ...a.entries.map((e) => ` ${describeEntry(e)}`)
233
+ ]),
234
+ "",
235
+ "Name the revision by the file it is bound to, e.g.",
236
+ ` ${first.verb}: [{ "key": ${JSON.stringify(opKey(first.op))}, "path": ${JSON.stringify(example.authoredFile ?? "<the entry's authoredFile>")} }]`,
237
+ "Nothing was changed."
238
+ ].join("\n"));
239
+ }
240
+ // Fail-closed on a key that matches nothing: silently ignoring it would report the plan as
241
+ // reconciled while leaving the component exactly as it was. The "Present:" listing names
242
+ // each entry's bound file where a key carries more than one, so a caller who addressed the
243
+ // wrong revision can see which one they meant.
244
+ const unknown = resolved.filter((r) => r.kind === "unknown");
245
+ if (unknown.length > 0) {
246
+ const present = plan.entries.map(entryLabel).join(", ") || "(none)";
247
+ return fail("COMPONENT_NOT_IN_PLAN", `${extId}'s plan.json has no component(s): ${unknown.map((u) => opLabel(u.op)).join(", ")}. Present: ${present}. Nothing was changed.`);
248
+ }
249
+ // ONE ENTRY, ONE OPERATION. Naming the same revision twice — `drop` and `promote` on one
250
+ // entry, or the same key listed twice — used to produce a report claiming both happened
251
+ // when only one could: the promote clears a flag on a node the drop already detached from
252
+ // the document, so the plan says one thing and the answer says another.
253
+ const hits = resolved.filter((r) => r.kind === "hit");
254
+ const collided = hits.filter((r, i) => hits.some((other, j) => j !== i && other.entry === r.entry));
255
+ if (collided.length > 0) {
256
+ return fail("CONFLICTING_OPERATIONS", [
257
+ `${extId}: the same plan entry is named by more than one operation, so this call would report changes it cannot all make.`,
258
+ ...[...new Set(collided.map((r) => r.entry))].map((entry) => ` ${entry.key} ${describeEntry(entry)} — named by ${collided.filter((r) => r.entry === entry).map((r) => r.verb).join(" and ")}`),
259
+ "Nothing was changed."
260
+ ].join("\n"));
261
+ }
262
+ if (blocker) {
263
+ blocker.resolved = true;
264
+ blocker.answer = answer;
265
+ if (amended?.length)
266
+ blocker.amendedComponents = amended;
267
+ plan.dirty = true;
268
+ }
269
+ const dropped = [];
270
+ const promoted = [];
271
+ for (const r of hits) {
272
+ if (r.verb === "drop") {
273
+ removeEntry(plan, r.entry);
274
+ dropped.push(entryLabel(r.entry));
275
+ }
276
+ else {
277
+ // The return value says whether a flag was actually present; a promote of something
278
+ // never marked provisional stays a no-op that reports success, exactly as before.
279
+ clearProvisional(plan, r.entry);
280
+ promoted.push(entryLabel(r.entry));
281
+ }
282
+ }
283
+ // A blockerless reconcile leaves its own attributable record — the plan must state WHY a
284
+ // provisional flag cleared, or the next reader is back to archaeology.
285
+ if (!blocker) {
286
+ const existing = Array.isArray(plan.document.reconciliations) ? plan.document.reconciliations : [];
287
+ plan.document.reconciliations = [
288
+ ...existing,
289
+ { reason, ...(promoted.length ? { promoted } : {}), ...(dropped.length ? { dropped } : {}), at: new Date().toISOString() }
290
+ ];
291
+ plan.dirty = true;
292
+ }
293
+ await fs.writeFile(file, serializePlanFile(plan), "utf8");
294
+ const open = blockers.filter((b) => !b.resolved).length;
295
+ return ok([
296
+ blocker ? `${extId} blocker ${blockerId} recorded as answered.` : `${extId} plan reconciled (no local blocker).`,
297
+ blocker ? ` answer: ${answer}` : ` reason: ${reason}`,
298
+ ...(amended?.length ? [` amended: ${amended.join(", ")}`] : []),
299
+ ...(promoted.length ? [` promoted (no longer provisional, now assemblable): ${promoted.join(", ")}`] : []),
300
+ ...(dropped.length ? [` dropped from the plan (${before} -> ${componentCount()} components): ${dropped.join(", ")}`] : []),
301
+ open > 0
302
+ ? ` ${open} blocker(s) still open on this requirement.`
303
+ : " no blockers remain open on this requirement.",
304
+ "",
305
+ "If this answer changed an authored file, edit it under requirements/" + extId + "/authored/ —",
306
+ "the wave tree is a build artifact that direct_lint/direct_apply rebuild from that file on every call."
307
+ ].join("\n"), {
308
+ extId,
309
+ ...(blocker ? { blockerId, answer } : { reason }),
310
+ openBlockers: open,
311
+ ...(amended?.length ? { amended } : {}),
312
+ ...(promoted.length ? { promoted } : {}),
313
+ ...(dropped.length ? { dropped } : {})
314
+ });
315
+ }
316
+ catch (error) {
317
+ if (error instanceof WorkspaceError)
318
+ return fail(error.code, error.message);
319
+ return internalError(error);
320
+ }
321
+ });
322
+ }