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,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
|
+
}
|