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,105 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { IN2_HOME, isNpmInstall } from "./install.js";
6
+ // Compiled location is dist/core/paths.js, so the app root is two levels up.
7
+ const APP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ export const APP_ROOT = APP_DIR;
9
+ // Where durable machine state roots (.local/cc-mcp/: grants, telemetry outboxes, sessions, org
10
+ // mirrors). An npm install anchors it at ~/.in2-agent — NEVER relative to the package: the package
11
+ // directory is replaced wholesale on update and moves with the npm prefix, and grants that silently
12
+ // relocate on a Node upgrade are grants that silently vanish. A repo checkout keeps the old
13
+ // two-levels-up (the monorepo root), which is what every dev flow and test fixture assumes.
14
+ export const REPO_ROOT = process.env.CC_MCP_REPO_ROOT?.trim() || (isNpmInstall() ? IN2_HOME : path.resolve(APP_DIR, "..", ".."));
15
+ // Analyzer skills. The bundle stages harness/skills as <root>/skills, so a shipped install and a
16
+ // repo checkout resolve differently — and the shipped case is the one that used to be missing
17
+ // entirely, which left every consultant's supervisor with no SKILL.md to hand its analyzers.
18
+ export const SKILLS_DIR = existsSync(path.join(APP_DIR, "skills"))
19
+ ? path.join(APP_DIR, "skills")
20
+ : path.join(APP_DIR, "harness", "skills");
21
+ // The deep supervisor reference (full loop rationale, live-caught lemmas, refusal handling,
22
+ // successor-batch playbook). The bundle stages harness/supervisor-playbook.md as <root>/reference/
23
+ // supervisor-playbook.md — it used to not ship at all, so on a consultant's machine the MCP
24
+ // `instructions` were the ONLY contract text, and clients truncate those to a few KB.
25
+ export const SUPERVISOR_PLAYBOOK_PATH = existsSync(path.join(APP_DIR, "reference", "supervisor-playbook.md"))
26
+ ? path.join(APP_DIR, "reference", "supervisor-playbook.md")
27
+ : path.join(APP_DIR, "harness", "supervisor-playbook.md");
28
+ // How the supervisor talks to the human — translation table, card rules, report skeleton.
29
+ // Ships under reference/ with the CI guide (build-artifact copies harness/reference wholesale).
30
+ export const OPERATOR_VOICE_PATH = existsSync(path.join(APP_DIR, "reference", "operator-voice.md"))
31
+ ? path.join(APP_DIR, "reference", "operator-voice.md")
32
+ : path.join(APP_DIR, "harness", "reference", "operator-voice.md");
33
+ export const WORKSPACE_ROOT = process.env.CC_MCP_WORKSPACE_ROOT?.trim() ||
34
+ path.join(REPO_ROOT, ".local", "cc-mcp", "sessions");
35
+ /**
36
+ * Where the per-org git mirrors live, one directory per 18-char org id (origin R1).
37
+ *
38
+ * A SIBLING of the session workspace rather than a child, because the two have opposite lifetimes:
39
+ * a session is an engagement's working directory and is deletable once its evidence has shipped,
40
+ * while the mirror is the accumulated history of everything IN2 has ever done to that org and must
41
+ * outlive every session that contributed to it. Derived from WORKSPACE_ROOT so an install that
42
+ * relocates its workspace takes its mirrors with it rather than stranding them under the repo.
43
+ *
44
+ * No operator-facing git concept exists anywhere above this line — the mirror is a rebuildable
45
+ * cache read through `direct_history`, never a repository anyone is asked to think about.
46
+ */
47
+ export const ORGREPO_ROOT = process.env.CC_MCP_ORGREPO_ROOT?.trim() || path.resolve(WORKSPACE_ROOT, "..", "orgrepo");
48
+ /**
49
+ * Who the operator is, for the commit's `Operator:` trailer.
50
+ *
51
+ * NOT the commit author: the author and committer are fixed constants so that identical bundle
52
+ * sets produce identical SHAs on any machine (origin R5). Operator identity is a fact ABOUT the
53
+ * wave, so it rides a trailer where it cannot perturb the hash.
54
+ */
55
+ export const OPERATOR_IDENTITY = process.env.CC_MCP_OPERATOR?.trim() || undefined;
56
+ /**
57
+ * Content hash of the harness this install is RUNNING — the playbook plus every skill file.
58
+ *
59
+ * The skills are GENERATED from the harness, and the two travel in the same package, so they
60
+ * should always agree. They can still diverge in practice: the copy in ~/.claude/skills is only
61
+ * re-synced when a server STARTS, so a machine that updated the package but has not yet started a
62
+ * new session is following superseded instructions while reporting the new server version.
63
+ * Nothing surfaced that; it had to be inferred from behaviour.
64
+ *
65
+ * Hashing content rather than reading a declared version number means the answer cannot drift from
66
+ * what is actually on disk — a stamped number is a claim, a hash is a measurement.
67
+ *
68
+ * Cheap and cached: a handful of files, read once per process.
69
+ */
70
+ let cachedHarnessVersion;
71
+ export function harnessVersion() {
72
+ if (cachedHarnessVersion !== undefined)
73
+ return cachedHarnessVersion || undefined;
74
+ try {
75
+ const files = [];
76
+ if (existsSync(SUPERVISOR_PLAYBOOK_PATH))
77
+ files.push(SUPERVISOR_PLAYBOOK_PATH);
78
+ const walk = (dir) => {
79
+ if (!existsSync(dir))
80
+ return;
81
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
82
+ const full = path.join(dir, entry.name);
83
+ if (entry.isDirectory())
84
+ walk(full);
85
+ else if (entry.name.endsWith(".md"))
86
+ files.push(full);
87
+ }
88
+ };
89
+ walk(SKILLS_DIR);
90
+ if (files.length === 0) {
91
+ cachedHarnessVersion = "";
92
+ return undefined;
93
+ }
94
+ const hash = createHash("sha256");
95
+ for (const file of files)
96
+ hash.update(readFileSync(file));
97
+ cachedHarnessVersion = hash.digest("hex").slice(0, 16);
98
+ return cachedHarnessVersion;
99
+ }
100
+ catch {
101
+ // Never let a version stamp break telemetry.
102
+ cachedHarnessVersion = "";
103
+ return undefined;
104
+ }
105
+ }
@@ -0,0 +1,225 @@
1
+ // THE CHAPTER STRUCTURE OF A DELIVERY RUN — the vocabulary, the ledger, and the transition rule.
2
+ //
3
+ // WHY A RUN NEEDS EXPLICIT BOUNDARIES AT ALL, given that it already has a contract and a playbook.
4
+ //
5
+ // Three separate holes closed by one seam:
6
+ //
7
+ // 1. THE DOCTRINE DIET NEEDS A JUST-IN-TIME CHANNEL. The 2026-08-04 audit cut the resident
8
+ // contract to a loop SKELETON (`supervisorContract.ts`) on the measured ground that a client
9
+ // truncated it at byte 2,050. The expanded per-step detail had nowhere to go but the playbook,
10
+ // which is read once at the start of a run and is 60 KB — so the model reads the delivery
11
+ // rules an hour before it delivers, and the writeback rules ninety minutes before it writes
12
+ // back. `advance_phase` returns a chapter's instructions AT the chapter, which is the only
13
+ // arrival time that composes with a skeletal resident contract.
14
+ //
15
+ // 2. A RUN THAT DIES SHIPS NOTHING. `close_batch` is the only thing that flushes the telemetry
16
+ // outbox and uploads the transcript, and it runs at the END. A run killed at wave three — a
17
+ // Windows client teardown, a crashed host, an operator who closed the window — leaves every
18
+ // event of that batch in the local outbox with nothing scheduled to drain it, and no
19
+ // transcript on the ledger at all. A boundary is a natural, already-motivated moment to ship
20
+ // what has happened so far, and there are eight of them before the end.
21
+ //
22
+ // 3. A DEAD RUN CANNOT BE PICKED UP. Everything a session produced is on disk and session-scoped
23
+ // — brief, plan.json files, wave bundles, question rounds — but nothing recorded WHERE THE RUN
24
+ // HAD GOT TO, so a fresh session had to infer the position from artifacts and would guess
25
+ // wrong in the one direction that matters (a phase entered but not finished looks identical to
26
+ // one never entered). The ledger states it.
27
+ //
28
+ // THE LEDGER IS APPEND-ONLY JSONL, one line per boundary, under the session directory beside every
29
+ // other artifact. Append-only rather than a single mutable "current phase" file for the reason the
30
+ // wave bundles are append-only: the question a resumed session asks is not only "where are we" but
31
+ // "what was skipped, and when did this run stall", and a file that only holds the head cannot
32
+ // answer either.
33
+ import { promises as fs } from "node:fs";
34
+ import path from "node:path";
35
+ import { isoNow } from "./ids.js";
36
+ import { ensureDir, phaseLedgerFile } from "./workspace.js";
37
+ /**
38
+ * THE NINE CHAPTERS, IN ORDER. This array IS the order — every rule below indexes into it, so
39
+ * inserting a chapter here is the whole edit.
40
+ *
41
+ * They are named for what the OPERATOR would call the stretch of work, not for the tool that
42
+ * dominates it, because the same names appear in the progress lines the supervisor relays. The one
43
+ * exception is `parallel-analysis`, which is named for its mechanism because its mechanism is the
44
+ * thing most often got wrong (serial dispatch, or no fan-out at all).
45
+ */
46
+ export const CHAPTERS = [
47
+ "activation",
48
+ "intake",
49
+ "partitioning",
50
+ "parallel-analysis",
51
+ "clarification",
52
+ "scope-approval",
53
+ "delivery",
54
+ "review-results",
55
+ "writeback-close"
56
+ ];
57
+ export function isChapter(value) {
58
+ return CHAPTERS.includes(value);
59
+ }
60
+ export function chapterIndex(chapter) {
61
+ return CHAPTERS.indexOf(chapter);
62
+ }
63
+ /**
64
+ * The chapters a run may legitimately jump over.
65
+ *
66
+ * SMALL BATCHES REALLY DO SKIP THESE, and the playbook says so in as many words: three requirements
67
+ * two of which name the same object can be analyzed inline, which skips `partitioning` (there is
68
+ * nothing to partition) and `parallel-analysis` (there is no fan-out); a batch that raises no
69
+ * genuine fork skips `clarification`, because the playbook's own rule is that a question whose
70
+ * recommendation you would follow anyway is an assumption to state rather than a round-trip to
71
+ * spend. Refusing those would make the ledger a thing to work around.
72
+ *
73
+ * EVERY OTHER CHAPTER IS LOAD-BEARING and skipping it is a defect worth naming: a run cannot reach
74
+ * delivery without having read the requirements (`intake`), and it cannot deploy without an
75
+ * approval card (`scope-approval`). The one escape is stated in `planTransition` — ending the run
76
+ * early is always legal, so `writeback-close` is reachable from anywhere.
77
+ */
78
+ export const SKIPPABLE_CHAPTERS = new Set([
79
+ "partitioning",
80
+ "parallel-analysis",
81
+ "clarification"
82
+ ]);
83
+ export const PHASE_LEDGER_SCHEMA_VERSION = "cc-phase-ledger-v1";
84
+ /**
85
+ * Is this boundary legal, and what does crossing it imply was skipped?
86
+ *
87
+ * PURE, over the ledger's head plus the two chapter names, so the whole rule is testable without a
88
+ * session on disk — and so the refusal messages can be asserted verbatim, since they are the only
89
+ * thing a supervisor that got this wrong will read.
90
+ *
91
+ * THE RULE, in the order it is checked:
92
+ * - `entering` must be strictly after `completed`. A backwards or same-chapter move is refused:
93
+ * a second clarification round is not a boundary, it is more of one chapter.
94
+ * - `completed` may be AHEAD of the ledger's head (the run crossed boundaries without saying so,
95
+ * which is the common shape on a first adoption) but never BEHIND it.
96
+ * - Jumping over a non-skippable chapter is refused BY NAME, with one exception: ending the run
97
+ * is always legal, so `writeback-close` is reachable from any chapter. That exception is not a
98
+ * loophole, it is the doctrine — `close_batch` is called equally when a run ends badly, and a
99
+ * ledger that refused the boundary in front of it would be an argument for not closing.
100
+ */
101
+ export function planTransition(input) {
102
+ const { head } = input;
103
+ if (!isChapter(input.completed) || !isChapter(input.entering)) {
104
+ const bad = !isChapter(input.completed) ? input.completed : input.entering;
105
+ return {
106
+ ok: false,
107
+ code: "PHASE_UNKNOWN",
108
+ message: `${JSON.stringify(bad)} is not a chapter of a delivery run. The nine, in order: ${CHAPTERS.join(" -> ")}.`
109
+ };
110
+ }
111
+ const completed = input.completed;
112
+ const entering = input.entering;
113
+ const from = chapterIndex(completed);
114
+ const to = chapterIndex(entering);
115
+ if (to <= from) {
116
+ return {
117
+ ok: false,
118
+ code: "PHASE_OUT_OF_ORDER",
119
+ message: to === from
120
+ ? `You named ${completed} as both finished and entering. A second round inside one chapter is not a boundary — advance_phase marks the move BETWEEN chapters, so keep working and call it when ${entering} is genuinely done. The order is ${CHAPTERS.join(" -> ")}.`
121
+ : `${entering} comes BEFORE ${completed}, so this is a step backwards and nothing was recorded. Chapters only move forward; if earlier work has to be redone, do it inside the chapter you are in and cross the next boundary when it is finished. The order is ${CHAPTERS.join(" -> ")}.`
122
+ };
123
+ }
124
+ if (head !== null && from < chapterIndex(head)) {
125
+ return {
126
+ ok: false,
127
+ code: "PHASE_ALREADY_PASSED",
128
+ message: `This session's ledger already records ${head} as entered, so ${completed} is behind where the run stands and nothing was recorded. Call advance_phase with completed: ${JSON.stringify(head)} once ${head} is genuinely finished.`
129
+ };
130
+ }
131
+ const skipped = CHAPTERS.slice(from + 1, to);
132
+ const blocked = entering === "writeback-close" ? [] : skipped.filter((c) => !SKIPPABLE_CHAPTERS.has(c));
133
+ if (blocked.length > 0) {
134
+ return {
135
+ ok: false,
136
+ code: "PHASE_SKIP_NOT_ALLOWED",
137
+ message: [
138
+ `Going from ${completed} straight to ${entering} jumps over ${blocked.join(", ")}, which no run may skip — nothing was recorded.`,
139
+ `Skippable: ${[...SKIPPABLE_CHAPTERS].join(", ")} (a batch small enough to analyze inline really does skip the fan-out, and a batch that raises no genuine fork really does skip clarification).`,
140
+ `${blocked.join(", ")} ${blocked.length === 1 ? "is" : "are"} not: reaching ${entering} without ${blocked.length === 1 ? "it" : "them"} means the run has no ${blocked.includes("scope-approval") ? "approved plan" : "grounding"} behind it.`,
141
+ `If the run is ENDING here rather than progressing — rejected, blocked, stopped early — advance to writeback-close instead, which is reachable from anywhere, and then call close_batch.`
142
+ ].join("\n")
143
+ };
144
+ }
145
+ return { ok: true, skipped };
146
+ }
147
+ /**
148
+ * Every ledger entry this session has written, oldest first.
149
+ *
150
+ * TOLERANT LINE BY LINE, like every other reader of an append-only file here: a half-written final
151
+ * line after a hard kill is exactly the shape this file exists to survive, so it is skipped rather
152
+ * than allowed to make the whole ledger unreadable. A resumed session reading four good lines and
153
+ * ignoring a torn fifth knows more than one that refused the file.
154
+ */
155
+ export async function readPhaseLedger(sessionId) {
156
+ let raw;
157
+ try {
158
+ raw = await fs.readFile(phaseLedgerFile(sessionId), "utf8");
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ const entries = [];
164
+ for (const line of raw.split("\n")) {
165
+ if (!line.trim())
166
+ continue;
167
+ try {
168
+ const parsed = JSON.parse(line);
169
+ if (typeof parsed.entering === "string" && isChapter(parsed.entering))
170
+ entries.push(parsed);
171
+ }
172
+ catch {
173
+ // A torn final line is the expected cost of appending without a lock; it names no chapter.
174
+ }
175
+ }
176
+ return entries;
177
+ }
178
+ /** The chapter the run is IN — the last one entered, by any entry kind. */
179
+ export function ledgerHead(entries) {
180
+ return entries.length > 0 ? entries[entries.length - 1].entering : null;
181
+ }
182
+ /**
183
+ * Append one entry.
184
+ *
185
+ * `fs.appendFile` rather than the read-modify-write `writeJsonAtomic` every other session artifact
186
+ * uses, and that is the point of choosing JSONL: an append is the one file operation that cannot
187
+ * lose what is already there, which matters most for the artifact whose whole job is to survive a
188
+ * process that did not get to finish.
189
+ */
190
+ export async function appendPhaseLedger(entry) {
191
+ const file = phaseLedgerFile(entry.sessionId);
192
+ await ensureDir(path.dirname(file));
193
+ await fs.appendFile(file, `${JSON.stringify(entry)}\n`, "utf8");
194
+ }
195
+ export function newLedgerEntry(input) {
196
+ return {
197
+ schemaVersion: PHASE_LEDGER_SCHEMA_VERSION,
198
+ kind: input.kind,
199
+ at: input.at ?? isoNow(),
200
+ sessionId: input.sessionId,
201
+ ...(input.completed ? { completed: input.completed } : {}),
202
+ entering: input.entering,
203
+ ...(input.skipped && input.skipped.length > 0 ? { skipped: input.skipped } : {}),
204
+ ...(input.note ? { note: input.note } : {}),
205
+ ...(input.runId ? { runId: input.runId } : {}),
206
+ ...(input.batchId ? { batchId: input.batchId } : {})
207
+ };
208
+ }
209
+ /** How long the run has been sitting where it is — the first thing a resumed session states. */
210
+ export function describeStall(entries, now = new Date()) {
211
+ const last = entries[entries.length - 1];
212
+ if (!last)
213
+ return null;
214
+ const at = Date.parse(last.at);
215
+ if (!Number.isFinite(at))
216
+ return null;
217
+ const ms = Math.max(0, now.getTime() - at);
218
+ const minutes = Math.round(ms / 60_000);
219
+ if (minutes < 60)
220
+ return `${minutes} minute(s) ago`;
221
+ const hours = Math.round(ms / 3_600_000);
222
+ if (hours < 48)
223
+ return `${hours} hour(s) ago`;
224
+ return `${Math.round(ms / 86_400_000)} day(s) ago`;
225
+ }
@@ -0,0 +1,251 @@
1
+ import { promises as fs } from "node:fs";
2
+ /** The legal `intent` values, in one place, so the refusal message cannot drift from the parser. */
3
+ const INTENTS = ["create", "update", "delete"];
4
+ /** `CustomField:PV_Asset__c.PV_Critical__c` — component identity, unchanged by the revision model. */
5
+ export const componentKey = (typeName, fullName) => `${typeName}:${fullName}`;
6
+ /**
7
+ * Parse one requirement's plan.json.
8
+ *
9
+ * `extId` is the requirement the file was found under, which is authoritative over anything the
10
+ * document says about itself — the directory is what the requirements index and every writeback
11
+ * path key on.
12
+ */
13
+ export function parsePlanFile(text, extId, file) {
14
+ let document;
15
+ try {
16
+ document = JSON.parse(text);
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ error: {
22
+ code: "PLAN_MALFORMED",
23
+ message: `plan.json for ${extId} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
24
+ ...(file ? { file } : {})
25
+ }
26
+ };
27
+ }
28
+ if (document === null || typeof document !== "object" || Array.isArray(document)) {
29
+ return {
30
+ ok: false,
31
+ error: { code: "PLAN_SHAPE_INVALID", message: `plan.json for ${extId} is not a JSON object.`, ...(file ? { file } : {}) }
32
+ };
33
+ }
34
+ const doc = document;
35
+ const rawComponents = doc.components;
36
+ // A plan with no `components` key is legal — an analyzer that found nothing to own writes one.
37
+ // A `components` that is present but not a list is a shape error, not an empty plan: reading it
38
+ // as zero components would report a broken plan as a requirement that owns nothing.
39
+ if (rawComponents !== undefined && !Array.isArray(rawComponents)) {
40
+ return {
41
+ ok: false,
42
+ error: { code: "PLAN_SHAPE_INVALID", message: `plan.json for ${extId} has a \`components\` field that is not a list.`, ...(file ? { file } : {}) }
43
+ };
44
+ }
45
+ const entries = [];
46
+ const deprecations = [];
47
+ const skipped = [];
48
+ const refusals = [];
49
+ const components = (rawComponents ?? []);
50
+ for (let index = 0; index < components.length; index++) {
51
+ const node = components[index];
52
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
53
+ skipped.push({ extId, index, reason: "component entry is not an object" });
54
+ continue;
55
+ }
56
+ const raw = node;
57
+ const typeName = stringOrNull(raw.typeName);
58
+ const fullName = stringOrNull(raw.fullName);
59
+ // The three existing readers all `continue` past an entry missing either half of the identity.
60
+ // Kept — but recorded, because "the plan named something this could not identify" and "the plan
61
+ // named nothing" are different facts and only one of them needs an analyzer to go fix it.
62
+ if (!typeName || !fullName) {
63
+ skipped.push({ extId, index, reason: "component entry has no typeName and/or fullName" });
64
+ continue;
65
+ }
66
+ const key = componentKey(typeName, fullName);
67
+ let intent = null;
68
+ const rawIntent = raw.intent;
69
+ const authoredFile = stringOrNull(raw.authoredFile);
70
+ if (rawIntent !== undefined && rawIntent !== null) {
71
+ if (INTENTS.includes(rawIntent)) {
72
+ intent = rawIntent;
73
+ }
74
+ else {
75
+ // Not "needs inference": the entry DID declare something, and it was not a legal intent.
76
+ // Reading it as undeclared would run the inference fallback on an entry whose author
77
+ // believed they had settled the question.
78
+ refusals.push({
79
+ code: "INVALID_INTENT",
80
+ key,
81
+ message: `${extId} entry ${index} (${key}) declares intent ${JSON.stringify(rawIntent)}; the only legal values are ${INTENTS.map((i) => `"${i}"`).join(", ")}.`,
82
+ entries: [{ extId, index, key, authoredFile, intent: null }]
83
+ });
84
+ }
85
+ }
86
+ if (intent === "delete" && authoredFile !== null) {
87
+ // A deletion has no source file. Tolerating the binding would leave a file bound to an entry
88
+ // the assembler could still resolve — which deploys the component this entry declares gone.
89
+ // Refused rather than ignored: the author wrote a path, so they believed one belonged here,
90
+ // and dropping it silently would settle that disagreement without telling them.
91
+ refusals.push({
92
+ code: "DELETE_BINDS_FILE",
93
+ key,
94
+ message: [
95
+ `${extId} entry ${index} (${key}) declares intent "delete" and also binds authoredFile ${JSON.stringify(authoredFile)}.`,
96
+ "A deletion has no authored file — there is no source for a component that is being removed, and a bound file",
97
+ "is one the assembler can copy into a wave, deploying the very component this entry exists to delete.",
98
+ 'Drop the `authoredFile`, or declare the entry `intent: "update"` if the file is a state you meant to deploy.'
99
+ ].join("\n"),
100
+ entries: [{ extId, index, key, authoredFile, intent }]
101
+ });
102
+ }
103
+ const deprecatedAction = stringOrNull(raw.action);
104
+ if (deprecatedAction !== null) {
105
+ deprecations.push({ extId, key, field: "action", value: deprecatedAction });
106
+ }
107
+ entries.push({
108
+ typeName,
109
+ fullName,
110
+ key,
111
+ extId,
112
+ index,
113
+ intent,
114
+ authoredFile,
115
+ dependsOn: Array.isArray(raw.dependsOn) ? raw.dependsOn.filter((d) => typeof d === "string") : [],
116
+ // `deployable` defaults TRUE on absence — the same default all three readers use today.
117
+ deployable: raw.deployable !== false,
118
+ // Unrecognized causes read as null (ordinary manual, counted against the rate) — the
119
+ // fail-closed direction: a typo can under-exclude, never over-exclude.
120
+ routeCause: raw.routeCause === "not_metadata" || raw.routeCause === "impossible" ? raw.routeCause : null,
121
+ provisional: raw.provisional === true,
122
+ blockedBy: stringOrNull(raw.blockedBy),
123
+ deprecatedAction,
124
+ raw
125
+ });
126
+ }
127
+ refusals.push(...checkRevisionBindings(entries));
128
+ return {
129
+ ok: true,
130
+ plan: {
131
+ extId,
132
+ declaredExtId: stringOrNull(doc.extId),
133
+ entries,
134
+ deprecations,
135
+ refusals,
136
+ skipped,
137
+ document: doc,
138
+ source: text,
139
+ dirty: false
140
+ }
141
+ };
142
+ }
143
+ /** Read and parse a plan.json from disk. ENOENT is its own code — an unwritten plan is not a broken one. */
144
+ export async function readPlanFile(file, extId) {
145
+ let text;
146
+ try {
147
+ text = await fs.readFile(file, "utf8");
148
+ }
149
+ catch (error) {
150
+ const code = error?.code;
151
+ if (code === "ENOENT") {
152
+ return { ok: false, error: { code: "PLAN_NOT_FOUND", message: `No plan.json for ${extId} at ${file}. The analyzer has not run, or the extId is wrong.`, file } };
153
+ }
154
+ return {
155
+ ok: false,
156
+ error: { code: "PLAN_UNREADABLE", message: `plan.json for ${extId} at ${file} could not be read: ${error instanceof Error ? error.message : String(error)}`, file }
157
+ };
158
+ }
159
+ return parsePlanFile(text, extId, file);
160
+ }
161
+ /**
162
+ * R3, checked over whatever scope the caller hands in.
163
+ *
164
+ * BATCH-SCOPED BY DESIGN. The shape this model exists for — EV-31 creates the field, EV-40 updates
165
+ * it — puts the two revisions in two DIFFERENT plan.json files, where each file sees one entry per
166
+ * key and a per-file check would find nothing wrong. `parsePlanFile` runs this over its own entries
167
+ * so a within-file duplication still refuses at parse; callers that hold the whole batch
168
+ * (`plan_waves`, the internal assembler) must run it again over the union.
169
+ *
170
+ * Counts DEPLOYABLE, FILE-BEARING entries only. Two kinds are excluded because neither has a file
171
+ * to bind: a `deployable: false` entry is a route, not a revision (R4), and a `delete` entry is a
172
+ * component's removal, which no file expresses. Counting a delete would refuse the plainest shape
173
+ * this model exists to legalize — one requirement creates a component, another deletes it — by
174
+ * demanding an `authoredFile` on the create that single-revision discovery has always made optional.
175
+ */
176
+ export function checkRevisionBindings(entries) {
177
+ const byKey = new Map();
178
+ for (const entry of entries) {
179
+ if (!entry.deployable || entry.intent === "delete")
180
+ continue;
181
+ const group = byKey.get(entry.key) ?? [];
182
+ group.push(entry);
183
+ byKey.set(entry.key, group);
184
+ }
185
+ const refusals = [];
186
+ for (const [key, group] of byKey) {
187
+ if (group.length < 2)
188
+ continue;
189
+ const unbound = group.filter((e) => e.authoredFile === null);
190
+ if (unbound.length === 0)
191
+ continue;
192
+ refusals.push({
193
+ code: "MISSING_REVISION_BINDING",
194
+ key,
195
+ message: [
196
+ `${key} has ${group.length} entries — two revisions of one component — but ${unbound.length} of them declare no \`authoredFile\`.`,
197
+ "With more than one entry per component, nothing can tell which authored file belongs to which revision;",
198
+ "every entry for the key must name its own file. Entries:",
199
+ ...group.map((e) => ` ${e.extId} entry ${e.index}: intent=${e.intent ?? "(undeclared)"} authoredFile=${e.authoredFile ?? "(none)"}`)
200
+ ].join("\n"),
201
+ entries: group.map(entryRef)
202
+ });
203
+ }
204
+ return refusals;
205
+ }
206
+ /** Point a refusal at an entry without handing out the mutable document node. */
207
+ export function entryRef(entry) {
208
+ return { extId: entry.extId, index: entry.index, key: entry.key, authoredFile: entry.authoredFile, intent: entry.intent };
209
+ }
210
+ /**
211
+ * Render the plan back to text.
212
+ *
213
+ * Byte-identical to what was parsed until something actually changed it — a read-modify-write that
214
+ * modified nothing must not reformat a file the operator hand-edited, and a diff that shows every
215
+ * line moved is a diff nobody reads. After a mutation it renders at the same 2-space indent with a
216
+ * trailing newline `tools/analyze.ts:176` already writes, so the only churn is the change itself.
217
+ */
218
+ export function serializePlanFile(plan) {
219
+ if (!plan.dirty)
220
+ return plan.source;
221
+ return `${JSON.stringify(plan.document, null, 2)}\n`;
222
+ }
223
+ /**
224
+ * Drop one entry — `resolve_blocker`'s `drop`, addressed at a single revision.
225
+ *
226
+ * Takes the ENTRY, not the key: under the revision model a key can name two entries, and the whole
227
+ * point of addressing by entry is that dropping "the component" would take both.
228
+ */
229
+ export function removeEntry(plan, entry) {
230
+ const components = plan.document.components;
231
+ if (!Array.isArray(components))
232
+ return false;
233
+ const at = components.indexOf(entry.raw);
234
+ if (at < 0)
235
+ return false;
236
+ components.splice(at, 1);
237
+ plan.dirty = true;
238
+ return true;
239
+ }
240
+ /** Clear `provisional`/`blockedBy` on one entry — `resolve_blocker`'s `promote`, per revision. */
241
+ export function clearProvisional(plan, entry) {
242
+ if (!("provisional" in entry.raw) && !("blockedBy" in entry.raw))
243
+ return false;
244
+ delete entry.raw.provisional;
245
+ delete entry.raw.blockedBy;
246
+ plan.dirty = true;
247
+ return true;
248
+ }
249
+ function stringOrNull(value) {
250
+ return typeof value === "string" && value.length > 0 ? value : null;
251
+ }