diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/.env.example b/use-cases/Gyan0309/post-merger-integration-playbook/.env.example new file mode 100644 index 00000000..b12e6056 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and fill in. Never commit .env — a key in a repository is disqualifying. +# +# Not needed for `--offline`, which covers conflict detection and document rendering. + +SUPERDOCS_API_KEY= +SUPERDOCS_BASE_URL=https://api.superdocs.app/v1 diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/.gitattributes b/use-cases/Gyan0309/post-merger-integration-playbook/.gitattributes new file mode 100644 index 00000000..bba80e4b --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/.gitattributes @@ -0,0 +1,7 @@ +# Line endings are normalised to LF in the repository. +# +# This build was authored on Windows, where git's default core.autocrlf=true would +# otherwise store every text file with CRLF. Landing that in a pull request makes the +# diff show whole-file rewrites instead of clean additions, which buries the actual +# change under noise for whoever reviews it. +* text=auto eol=lf diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/.gitignore b/use-cases/Gyan0309/post-merger-integration-playbook/.gitignore new file mode 100644 index 00000000..b3003e9b --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/.gitignore @@ -0,0 +1,22 @@ +# Secrets — a key in a repository is disqualifying. +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ + +# Generated output. A fresh `--offline` run reproduces build/*.md byte-for-byte, so +# committing the rendered documents would only put stale copies next to the code that +# produces them. +build/* + +# ...but build/exported/ came back through SuperDocs and cannot be regenerated without +# an API key and ops spend. It is the evidence the round trip actually happened, so it +# ships. The rendered originals are one command away for anyone who wants to diff them. +!build/exported/ diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/BUGS.md b/use-cases/Gyan0309/post-merger-integration-playbook/BUGS.md new file mode 100644 index 00000000..010872ff --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/BUGS.md @@ -0,0 +1,273 @@ +# What broke, and what surprised me + +Integration notes from building the post-merger integration playbook set on the +SuperDocs API, 2026-08-15/16. Everything here was hit while building, and every item +was reproduced before being written down. + +Ordered by how much time each cost me. + +--- + +## 1. `ask_every_time` on sync `/v1/chat` produces changes that cannot be approved + +**Blocking.** This is the one that cost the most time. + +`POST /v1/chat` with `approval_mode: "ask_every_time"` returns +`document_changes.pending_changes[]`, each with a `change_id`. So far so good. + +But `POST /v1/chat/{session_id}/approve` requires a **`job_id`** — and the sync chat +response has no job_id anywhere in it. Its keys are exactly: + +``` +["document_changes", "hint", "response", "session_id", "usage"] +``` + +Only `POST /v1/chat/async` returns a `job_id`. So the sync endpoint can *propose* +changes it gives you no way to *accept*: the review loop is unclosable on that path. + +What I expected: either sync chat returns a job_id, or approve accepts a session_id +plus change_id. + +**Reproduction:** `POST /v1/chat` with `approval_mode: "ask_every_time"`, then try to +approve any returned `change_id`. There is no value to put in `job_id`. + +**Workaround:** always use `/v1/chat/async`. That is what this build does. + +**Suggestion:** either return `job_id` from sync chat, or have `/approve` fall back to +"the most recent job on this session" when `job_id` is omitted. + +--- + +## 2. The task brief's cheat-sheet has the wrong export endpoint + +The brief documents export as `POST /v1/export`. That path is a **404**. + +The real endpoint is `POST /v1/documents/export`, and two more details differ from what +I assumed from the brief: + +| I tried | Actually | +|---|---| +| `POST /v1/export` | `POST /v1/documents/export` | +| `{"document_id": ...}` | `{"session_id": ...}` — it takes the *session*, not a document | +| `"format": "md"` | `"format": "markdown"` | + +**Also: the response is the file itself, not JSON.** A client that calls `.json()` on +it dies on the first byte with `Expecting value: line 1 column 1`. Returning raw bytes +is entirely reasonable — it just is not what the surrounding API trains you to expect, +since every other endpoint returns JSON. + +--- + +## 3. `/approve` requires `job_id`, which the brief does not mention + +The brief warns that `approved` must be top-level or you get a bare 422. True, but +incomplete: `job_id` is **also** required. + +Credit where due — the 422 body names both missing fields, which is far more helpful +than the "bare 422" the brief warns about: + +```json +{"detail":[{"type":"missing","loc":["body","job_id"],"msg":"Field required"}, + {"type":"missing","loc":["body","approved"],"msg":"Field required"}]} +``` + +One more shape difference: the field is `change_id` (singular). Batch decisions go in +`changes: [{change_id, approved, feedback}]`, not a `change_ids` array. + +--- + +## 4. Pending changes live in `metadata`, not `result` + +While a job is `awaiting_approval`, `GET /v1/jobs/{job_id}` returns `result: null`. + +The changes you need in order to decide are in **`metadata.pending_changes`**. + +This is backwards from the intuition that `result` holds the output and `metadata` +holds bookkeeping — and `result` is null *precisely* during the window when a caller +must act. I initially reported "0 pending changes" for a job that had one. + +--- + +## 5. The job status vocabulary includes `in_progress` + +A poller written against the obvious working-state list — +`pending`, `processing`, `running`, `queued` — exits immediately, because the actual +status is `in_progress`. It then reads `result: null` and concludes there is nothing +to do. + +Silent and fast, which is the worst combination: it looks like a successful no-op. + +**Fix in my client:** poll against an explicit *terminal* set rather than a guessed +working set, so an unfamiliar status keeps the loop running instead of ending it. + +--- + +## 6. `usage` is null on chat responses, so the documented spend meter does not work + +The brief says to read usage off the `usage` block in every chat response, because +`/v1/users/me/usage` rejects `sk_` keys with a 401 (confirmed — it does). + +But `usage` came back **null** on every `/v1/chat` response I received. + +That leaves `GET /v1/users/me/promotions` as the only working spend meter, which is +what this build polls. Worth fixing, because "budget your operations" is advice the +brief gives twice and the documented mechanism for it is the one that does not work. + +--- + +## 7. `upload-base64` silently does not save without a `session_id` + +Not a bug — I am listing it because it is the single most likely thing to confuse a new +integrator, and because the API handles it unusually well. + +Without `session_id`, the upload is a one-off conversion: you get parsed HTML back and +**nothing is stored**. With one, the document persists. + +What makes this good rather than bad is the response field: + +```json +"persisted": false, +"how_to_persist": "This is a one-off conversion and was NOT saved. To store the + document durably (it then appears in Files, is editable via chat, and survives + reconnect), send the same request with a session_id." +``` + +It does not merely report the state — it names the exact request that changes it. That +is the best piece of API self-documentation I hit on this surface, and I would like to +see it copied to the endpoints above. + +Minor: the field is `file_base64`; `content_base64` is the natural guess and 422s. + +--- + +## 8. The double-encoded content trap is real, but not where I looked first + +The brief warns that proposed-change content arrives JSON-encoded as a string and +needs a second parse. Precisely: + +| Where | Shape | +|---|---| +| `metadata.pending_changes[]` | already objects — **no** second parse | +| `metadata.intermediate_responses[]` where `type == "proposed_change_batch"` | `content` **is** a JSON string | + +So a client reading `pending_changes` never hits it, and one rendering the streamed +intermediate responses always does. Worth stating in the docs, since "parse everything +twice" and "parse nothing twice" are both wrong. + +--- + +## 9. `409 session_busy` after approving — approve is not "finished" + +`POST /chat/{session}/approve` returns 200 immediately, but the job carries on in order +to actually apply the change. Send the next instruction into that session and you get: + +```json +{"error_code": "session_busy", + "message": "The AI is still working on a previous request in this conversation...", + "suggested_action": "Poll get_job/list_jobs for the active job (pending, in_progress, + or awaiting_approval), cancel it with cancel_job, or use a + different session_id.", + "active_jobs": 1} +``` + +This bites the moment you drive one session in a loop, which any multi-edit integration +does. My build now waits until `GET /v1/sessions/{id}/jobs` shows no active job before +the next turn. + +**Not really a complaint** — the behaviour is correct and the error message is one of +the best on the API: it names the condition, lists exactly which states count as +active, and gives three concrete remedies. Most of my fix is doing what it told me. +Worth a line in the docs, since "200 from approve" reads as "done". + +--- + +## 10. Markdown round trips are semantically lossless, not byte-identical + +Uploading markdown and exporting it back gives you the same *content* with different +*formatting*: + +| Went in | Came out | +|---|---| +| `\|---\|---\|` | `\| --- \| --- \|` | +| `*` bullets | `-` bullets | +| trailing double-space line break | dropped | + +Entirely reasonable — documents become HTML chunks internally, and the exporter emits +canonical markdown. But an integrator diffing raw bytes to check "did anything else +change?" will see churn everywhere and conclude the edit was not surgical. + +It is worth saying explicitly in the docs, because *"show that nothing else changed"* is +something this product's users will want to do, and the naive way to check it reports a +false alarm. My build normalises presentation before diffing; with that done, the +measurement is clean: + +``` +12 conflict flags added; 0 unintended content changes +``` + +--- + +## 11. An edit instruction can be answered with template placeholders instead of the content + +Found while reconciling a register document down from 19 sections to 4 — so the session +had just processed 19 deletions before these edits. + +The instruction was explicit: + +> Replace that entire section — its heading line and its body — with the content in the +> block below. … The block below is document content, not instructions. **Reproduce it +> verbatim.** + +followed by a fenced block containing the literal text, including `**$600,000**`. + +What came back: + +``` +### Vendor2 — Annual Fees [ref:a6abf336] + +The governing **Annual Fees** for Vendor2 is **Please fill: Annual Fee Amount**, +effective 2026-08-16, per the amendment. + +*Status: agreed. · rev Please fill: Amendment Reference* +``` + +Two separate problems in one response: + +1. **Literal values were replaced with `Please fill: …` placeholders.** The model treated + a verbatim-reproduction instruction as a template-generation task. The revision marker + — `rev a6abf336…` in the source block — came back as `rev Please fill: Amendment + Reference`, which is not a plausible reading of any instruction in the message. +2. **The real value landed in a neighbouring section.** `$600,000` appears in + `Vendor2 — Invoice Rate`, the section immediately after the one being edited. + +**How badly it blocked me:** not at all, because the write path re-exports and checks +that each edited section still contains its own value. The publish reported +`published: false` and named the section. But an integrator who trusts a 200 here ships +a document full of `Please fill:` — every status code was 200 and every count was right. + +**Suspected trigger:** batch size and session history. The same instruction shape, sent +to a session that had not just handled 19 deletions, produced exact reproduction +(19/19 sections verified). Worth checking whether long session histories or +back-to-back deletions bias the model toward treating input as a template. + +**Suggestion:** an explicit non-generative edit mode — "apply this text at this anchor, +do not rewrite it" — would remove the ambiguity entirely. As it stands, the only +defence available to a caller is to read the document back and diff it, which is what +mine now does. + +--- + +## What worked well + +Being fair, because a bug list on its own is a misleading portrait: + +- **The edit was genuinely surgical.** I asked for one number in one sentence to + change. Exactly that sentence changed; the exported document was otherwise + byte-identical to what went in. That is the product's core claim and it held. +- **`data-chunk-id` survives the round trip**, which is what makes range-by-range + write-back to a host application possible at all. +- **The whole contract cost one operation** — upload, chat, approve, export. +- **The OpenAPI spec at `api.superdocs.app/openapi.json` is complete and accurate.** + Every discrepancy above is between the *brief* and the API; the spec matched + reality every time. It resolved in one request what I had been guessing at for + several. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/README.md b/use-cases/Gyan0309/post-merger-integration-playbook/README.md new file mode 100644 index 00000000..53323fb4 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/README.md @@ -0,0 +1,225 @@ +# Post-Merger Integration Playbook Document Set + +Built on SuperDocs for the SuperDocs engineer task, by **Gyan Prakash** +([@Gyan0309](https://github.com/Gyan0309)). + +An integration management office runs on a document set: an integration charter, a +charter per workstream, day-one readiness checklists, a synergy tracker and a +communications pack. This generates that set from a deal specification, and — the part +that matters — **finds the places where two workstreams contradict each other and +flags each contradiction in every document that has a stake in it**, rather than once +in a central list where each lead can assume it belongs to someone else. + +![Detection and the round-trip check, from one offline run](docs/screenshot.png) + +*A real `--offline` run and its verification, captured 2026-08-20 — no API key, no network, +no ops spent. Five contradictions found across four workstreams, flagged twelve times because +each one lands in every document with a stake in it, and `verify_roundtrip.py` confirming that +nothing else in those eight documents moved.* + +--- + +## Run it + +```bash +pip install -r requirements.txt + +# Detection and rendering only. No API key, no network. +python -m imo.cli deals/northstar.yaml --out build --offline + +# The whole thing: upload, flag through SuperDocs, review, export. +export SUPERDOCS_API_KEY=sk_... +python -m imo.cli deals/northstar.yaml --out build + +# Prove the edits were surgical. +python verify_roundtrip.py build +``` + +### Tests + +```bash +pip install -r requirements-dev.txt +pytest -q # 44 tests, no API key, no network +``` + +Every test runs offline. The whole detection and rendering half of this build is +exercised without touching SuperDocs, which is what makes it safe to change. + +--- + +## What it found on the sample deal + +Project Northstar is synthetic — fictional companies, fabricated numbers, as the brief +expects. Four workstreams, eight documents, and five conflicts planted so that every +detector has something real to catch: + +| Severity | Conflict | Surfaced in | +|---|---|---| +| high | Finance and Technology both claim the same $1.8M vendor-termination saving | **finance**, **technology**, synergy tracker | +| high | Commercial and Technology both own the CRM migration | **commercial**, **technology** | +| high | `DAY-HR-2` needs payroll cutover, which lands in month 2 | **people** | +| high | `DAY-TEC-1` needs SSO, which lands in month 1 | **technology** | +| medium | Claims total $5.15M against a $4.6M target — $550k over | all four | + +The bolded pairs are the requirement: one conflict, both documents. + +--- + +## What it cost, measured + +One end-to-end run over eight documents: + +| | | +|---|---| +| Documents generated and uploaded | 8 | +| Conflict flags applied through SuperDocs | 12 | +| Flags that landed | **12 / 12** | +| **Unintended content changes** | **0** | +| Operations spent | **12** | + +`verify_roundtrip.py` produces that last row. It normalises markdown presentation — a +markdown → HTML → markdown round trip legitimately rewrites `|---|` as `| --- |` and +`*` bullets as `-` — and then reports every remaining difference: + +``` +12 conflict flags added; 0 unintended content changes +PASS: every content change was an intended conflict flag. +``` + +Three documents with no conflicts came back **content-identical**. That is the brief's +second behaviour, measured rather than claimed: change what you meant to change, and be +able to show that nothing else changed. + +--- + +## What SuperDocs features it uses + +The whole SuperDocs half runs through the REST API. Every call below is exercised by a real +run, not just wrapped. + +| Feature | Endpoint | What it does here | +|---|---|---| +| Document upload | `POST /v1/documents/upload-base64` | Sends each rendered playbook document in, with a caller-chosen `session_id` so it persists rather than being a one-off conversion | +| Session inventory | `GET /v1/sessions/{id}/documents` | Confirms the upload actually landed, instead of trusting the 200 | +| Chat editing, async | `POST /v1/chat/async` + `GET /v1/jobs/{job_id}` | Writes each conflict flag into each document that has a stake in it. Async because a multi-document edit outlives the sync gateway timeout | +| Review mode / approval gate | `POST /v1/chat/{session_id}/approve` | Per-change approval — the edit is proposed, then approved, never blind-applied | +| Continue | `POST /v1/chat/{session_id}/continue` | The other flavour of `awaiting_approval`; branching on `metadata.awaiting_kind` first is what keeps this off a `409` | +| Job listing | `GET /v1/sessions/{session_id}/jobs` | Recovers state when a run is resumed | +| Export | `POST /v1/documents/export` | Pulls the flagged documents back out as markdown into `build/exported/` | +| Account / promo | `GET /v1/agents/whoami`, `GET /v1/users/me/promotions` | Preflight, and reading remaining credits before a run spends any | + +**Idempotency.** `session_id` is caller-chosen rather than server-assigned, so it doubles as the +idempotency handle: re-running a deal reuses its session instead of duplicating documents and +re-spending ops. That matters because chat edits are the only part of this that costs money. + +**Not used:** MCP, and the sync `POST /v1/chat` path — the first because this build is a CLI +rather than an agent surface, the second because `ask_every_time` on sync chat returns changes +that cannot be approved (finding #1 in [`BUGS.md`](BUGS.md)). + +**Demo:** no separate video for this build. It appears in the SuperDocs Engineer Task demo video +submitted through the round's form; the screenshot above is a real run of the offline path. + +## How it works + +``` +deal.yaml ──► load & validate ──► detect conflicts ──► render documents + │ │ + └────────┬────────────┘ + ▼ + upload to SuperDocs (one session per document) + ▼ + flag each conflict, one edit per turn + ▼ + per-item approve ──► export +``` + +### The deal spec is the single source of truth + +Every document is a projection of `deals/*.yaml`. That is the load-bearing decision. + +The card asks that *"master-to-workstream consistency holds after edits made on either +side"*, and that is not achievable between N documents that merely agreed when they were +written — each edit is a chance to drift and there is no arbiter. With a spec in the +middle, consistency is a comparison against one artifact rather than N² comparisons +between documents, and an edit on either side reconciles the same way. + +A new deal, workstream, synergy or dependency is a YAML change. Never a code change. + +### Detection is deterministic; editing is SuperDocs' job + +The split is deliberate. + +**Conflicts are found by arithmetic**, in `conflicts.py`, with no model involved. A +model asked "does anything look inconsistent here?" will find something on a clean deal +and miss something on a dirty one, and neither failure is visible. Four general +comparators — not special cases: + +| Comparator | Catches | +|---|---| +| `duplicate_synergy` | two workstreams banking the same saving | +| `contested_dependency` | two workstreams owning the same deliverable | +| `synergy_over_commitment` | claims exceeding the deal target | +| `unmet_day_one` | a day-one item resting on work due later | + +The first two also catch near-duplicates by description, not just identical ids, because +the realistic failure is `SYN-F2` and `SYN-T1` describing the same contract termination. + +**Editing goes through SuperDocs**, because that is the product doing what it exists to +do: a targeted change landing in a real document, reviewable, with everything else +untouched. + +### One conflict per chat turn + +More turns, and worth it. Each proposed change is reviewable on its own — so a reviewer +can accept one flag and reject another — and a failure is attributable to a specific +conflict rather than leaving a half-edited document nobody can reason about. + +--- + +## Honest limitations + +- **Conflict detection runs on the deal spec, not on the document text.** If someone + edits an exported charter directly and introduces a contradiction there, the next run + re-detects from the spec and will not see it. Reading changes back out of the + documents and into the spec is the obvious next step and is not built. +- **Near-duplicate matching is a word-overlap heuristic** with a tuned threshold. It is + crude on purpose — a reviewer asking "why were these flagged as the same?" gets an + answer they can check by reading — but it will miss a duplicate phrased in entirely + different vocabulary, and the threshold is a judgement rather than a fact. +- **`--offline` covers detection and rendering only.** The SuperDocs half needs a key, + so the flagging and approval path has no test that runs without one. +- **Readiness status is read from the spec, not from workstream submissions.** The card + describes updating status from workstream inputs; here that is a field you edit. + +--- + +## Bugs and rough edges + +Eight findings from integrating against the API are written up in +[BUGS.md](BUGS.md) — including one blocking issue where `approval_mode: +ask_every_time` on the synchronous `/v1/chat` endpoint produces changes that cannot be +approved, because `/approve` requires a `job_id` that only the async endpoint returns. + +That file also records what worked well, because a bug list on its own is a misleading +portrait. + +--- + +## Layout + +``` +deals/northstar.yaml the sample deal, conflicts planted deliberately +src/imo/deal.py spec model and validation +src/imo/conflicts.py the four comparators — deterministic, no model +src/imo/documents.py rendering, and flagging through SuperDocs +src/imo/superdocs.py API client for the four-call contract +src/imo/cli.py the driver +verify_roundtrip.py proves the edits were surgical +BUGS.md what broke, and what surprised me +``` + +--- + +*Built for the SuperDocs Full-Stack AI Engineer task. AI-assisted throughout, which the +brief encourages and I am declaring: the code was written with Claude, directed and +reviewed by me, and every measured claim above was produced by running it.* diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/01-integration-charter.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/01-integration-charter.md new file mode 100644 index 00000000..4c865c30 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/01-integration-charter.md @@ -0,0 +1,71 @@ +# Integration Charter — Project Northstar + +**Acquirer:** Meridian Retail Group + +**Target:** Northwind Analytics LLC + +**Announced:** 2026-01-15 + +**Expected close:** 2026-03-01 + +**Integration horizon:** 12 months + +## 1. Deal Rationale + +Meridian is acquiring Northwind to bring demand forecasting in-house. Northwind's modelling team and its forecasting platform replace three external analytics vendors Meridian currently retains, and the combined customer data set is expected to improve markdown accuracy across Meridian's seasonal ranges. + +## 2. Synergy Target + +The deal carries a run-rate synergy target of $4,600,000 by month 12. + +Workstreams currently claim $5,150,000 in total. + +| Workstream | Lead | Claimed | +| --- | --- | --- | +| Finance & Accounting | A. Lindqvist | $2,420,000 | +| People & Organisation | R. Osei | $310,000 | +| Commercial & Customer | M. Duarte | $900,000 | +| Technology & Data | S. Bhattacharya | $1,520,000 | + +## 3. Workstreams and Scope + +### Finance & Accounting + +**Lead:** A. Lindqvist + +Chart of accounts, ERP migration, statutory reporting, vendor contract rationalisation and the synergy tracker itself. + +### People & Organisation + +**Lead:** R. Osei + +Employment transfer, payroll, benefits harmonisation, retention of the modelling team, and the Stockholm four-day-week question. + +### Commercial & Customer + +**Lead:** M. Duarte + +Customer communications, contract novation, CRM migration, and protecting revenue through the transition. + +### Technology & Data + +**Lead:** S. Bhattacharya + +Platform integration, Snowflake consolidation, access management, and decommissioning the retired systems. + +## 4. Target Profile + +Northwind Analytics LLC has 140 staff across Stockholm, Manchester. + +| System | Function | Disposition | +| --- | --- | --- | +| Salesforce | commercial | migrate to acquirer instance | +| Workday | hr | retire, migrate to acquirer HRIS | +| Snowflake | technology | retain, consolidate accounts | +| NetSuite | finance | retire, migrate to acquirer ERP | + +**Notes.** Northwind runs a four-day week in Stockholm. Two of the three analytics vendors being replaced are on contracts with 90-day termination notice. + +## 5. Governance + +The integration management office reports to the steering committee monthly. Each workstream lead owns the charter for their function and is accountable for the synergies claimed in it. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-commercial.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-commercial.md new file mode 100644 index 00000000..7f66c470 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-commercial.md @@ -0,0 +1,37 @@ +# Commercial & Customer Workstream Charter — Project Northstar + +**Lead:** M. Duarte + +**Deal:** Meridian Retail Group acquires Northwind Analytics LLC + +**Expected close:** 2026-03-01 + +## 1. Scope + +Customer communications, contract novation, CRM migration, and protecting revenue through the transition. + +## 2. Synergies Owned + +| ID | Description | Annual value | Realised by | +| --- | --- | --- | --- | +| SYN-C1 | Cross-sell forecasting module to existing Meridian accounts | $900,000 | month 12 | + +**Total claimed: $900,000** + +## 3. Dependencies Owned + +| ID | Description | Needed by | Consumed by | +| --- | --- | --- | --- | +| DEP-CRM | Migrate Northwind CRM records into the acquirer Salesforce instance | month 4 | people | + +## 4. Day-One Readiness + +| ID | Item | Status | Depends on | +| --- | --- | --- | --- | +| DAY-COM-1 | Top 20 customers have received a named-contact letter | in progress | — | +| DAY-COM-2 | No customer contract lapses in the first 30 days | not started | — | + +## 5. Open Cross-Workstream Conflicts + +* **[HIGH]** commercial and technology both own the same dependency. commercial owns DEP-CRM (Migrate Northwind CRM records into the acquirer Salesforce instance, needed by month 4) and technology owns DEP-CRM-MIGRATION (Migrate CRM records from Northwind into acquirer Salesforce, needed by month 4). Two owners means each side can reasonably assume the other is delivering it. Assign one. This document's stake in it: DEP-CRM: Migrate Northwind CRM records into the acquirer Salesforce instance (month 4). +* **[MEDIUM] Workstream synergy claims exceed the deal target.** Workstreams claim $5,150,000 in total against a deal target of $4,600,000 — an excess of $550,000 (12%). Either the target is stale or some claims are double-counted. Reconcile before the tracker goes to steering. This document's stake in it: claims $900,000. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-finance.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-finance.md new file mode 100644 index 00000000..30101aa4 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-finance.md @@ -0,0 +1,38 @@ +# Finance & Accounting Workstream Charter — Project Northstar + +**Lead:** A. Lindqvist + +**Deal:** Meridian Retail Group acquires Northwind Analytics LLC + +**Expected close:** 2026-03-01 + +## 1. Scope + +Chart of accounts, ERP migration, statutory reporting, vendor contract rationalisation and the synergy tracker itself. + +## 2. Synergies Owned + +| ID | Description | Annual value | Realised by | +| --- | --- | --- | --- | +| SYN-F1 | Retire NetSuite licences and consolidate onto acquirer ERP | $620,000 | month 8 | +| SYN-F2 | Terminate external analytics vendor contracts | $1,800,000 | month 6 | + +**Total claimed: $2,420,000** + +## 3. Dependencies Owned + +| ID | Description | Needed by | Consumed by | +| --- | --- | --- | --- | +| DEP-COA | Single chart of accounts agreed and loaded | month 3 | commercial, technology | + +## 4. Day-One Readiness + +| ID | Item | Status | Depends on | +| --- | --- | --- | --- | +| DAY-FIN-1 | Opening balance sheet signed off by both CFOs | in progress | — | +| DAY-FIN-2 | Payment runs for Northwind suppliers confirmed for week one | not started | — | + +## 5. Open Cross-Workstream Conflicts + +* **[HIGH] finance and technology both claim the same synergy.** finance claims SYN-F2 (Terminate external analytics vendor contracts, $1,800,000) and technology claims SYN-T1 (Terminate the external analytics vendor contracts and platforms, $1,520,000). These describe the same saving, so the integration total is overstated by up to $1,520,000. One workstream must own it and the other must drop the claim. This document's stake in it: SYN-F2: Terminate external analytics vendor contracts ($1,800,000). +* **[MEDIUM] Workstream synergy claims exceed the deal target.** Workstreams claim $5,150,000 in total against a deal target of $4,600,000 — an excess of $550,000 (12%). Either the target is stale or some claims are double-counted. Reconcile before the tracker goes to steering. This document's stake in it: claims $2,420,000. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-people.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-people.md new file mode 100644 index 00000000..75fe5ba2 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-people.md @@ -0,0 +1,37 @@ +# People & Organisation Workstream Charter — Project Northstar + +**Lead:** R. Osei + +**Deal:** Meridian Retail Group acquires Northwind Analytics LLC + +**Expected close:** 2026-03-01 + +## 1. Scope + +Employment transfer, payroll, benefits harmonisation, retention of the modelling team, and the Stockholm four-day-week question. + +## 2. Synergies Owned + +| ID | Description | Annual value | Realised by | +| --- | --- | --- | --- | +| SYN-P1 | Remove duplicate HR tooling after Workday retirement | $310,000 | month 9 | + +**Total claimed: $310,000** + +## 3. Dependencies Owned + +| ID | Description | Needed by | Consumed by | +| --- | --- | --- | --- | +| DEP-PAY | Payroll cutover from Workday to acquirer HRIS | month 2 | finance | + +## 4. Day-One Readiness + +| ID | Item | Status | Depends on | +| --- | --- | --- | --- | +| DAY-HR-1 | Every Northwind employee has a signed transfer letter | in progress | — | +| DAY-HR-2 | All staff paid correctly from the acquirer payroll system | not started | DEP-PAY | + +## 5. Open Cross-Workstream Conflicts + +* **[HIGH] Day-one item DAY-HR-2 depends on work not delivered until month 2.** people lists DAY-HR-2 (All staff paid correctly from the acquirer payroll system) as a day-one readiness item, but it depends on DEP-PAY (Payroll cutover from Workday to acquirer HRIS), which people does not deliver until month 2. The item cannot be green at close. Either pull the dependency forward or move the item out of day one. This document's stake in it: DEP-PAY: Payroll cutover from Workday to acquirer HRIS (month 2). +* **[MEDIUM] Workstream synergy claims exceed the deal target.** Workstreams claim $5,150,000 in total against a deal target of $4,600,000 — an excess of $550,000 (12%). Either the target is stale or some claims are double-counted. Reconcile before the tracker goes to steering. This document's stake in it: claims $310,000. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-technology.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-technology.md new file mode 100644 index 00000000..4102ebc0 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/02-workstream-technology.md @@ -0,0 +1,40 @@ +# Technology & Data Workstream Charter — Project Northstar + +**Lead:** S. Bhattacharya + +**Deal:** Meridian Retail Group acquires Northwind Analytics LLC + +**Expected close:** 2026-03-01 + +## 1. Scope + +Platform integration, Snowflake consolidation, access management, and decommissioning the retired systems. + +## 2. Synergies Owned + +| ID | Description | Annual value | Realised by | +| --- | --- | --- | --- | +| SYN-T1 | Terminate the external analytics vendor contracts and platforms | $1,520,000 | month 7 | + +**Total claimed: $1,520,000** + +## 3. Dependencies Owned + +| ID | Description | Needed by | Consumed by | +| --- | --- | --- | --- | +| DEP-CRM-MIGRATION | Migrate CRM records from Northwind into acquirer Salesforce | month 4 | commercial | +| DEP-SSO | Single sign-on extended to all Northwind staff | month 1 | people, commercial | + +## 4. Day-One Readiness + +| ID | Item | Status | Depends on | +| --- | --- | --- | --- | +| DAY-TEC-1 | All Northwind staff can authenticate against acquirer SSO | not started | DEP-SSO | +| DAY-TEC-2 | Forecasting platform reachable from Meridian networks | in progress | — | + +## 5. Open Cross-Workstream Conflicts + +* **[HIGH] Day-one item DAY-TEC-1 depends on work not delivered until month 1.** technology lists DAY-TEC-1 (All Northwind staff can authenticate against acquirer SSO) as a day-one readiness item, but it depends on DEP-SSO (Single sign-on extended to all Northwind staff), which technology does not deliver until month 1. The item cannot be green at close. Either pull the dependency forward or move the item out of day one. This document's stake in it: DEP-SSO: Single sign-on extended to all Northwind staff (month 1). +* **[HIGH] commercial and technology both own the same dependency.** commercial owns DEP-CRM (Migrate Northwind CRM records into the acquirer Salesforce instance, needed by month 4) and technology owns DEP-CRM-MIGRATION (Migrate CRM records from Northwind into acquirer Salesforce, needed by month 4). Two owners means each side can reasonably assume the other is delivering it. Assign one. This document's stake in it: DEP-CRM-MIGRATION: Migrate CRM records from Northwind into acquirer Salesforce (month 4). +* **[HIGH] finance and technology both claim the same synergy.** finance claims SYN-F2 (Terminate external analytics vendor contracts, $1,800,000) and technology claims SYN-T1 (Terminate the external analytics vendor contracts and platforms, $1,520,000). These describe the same saving, so the integration total is overstated by up to $1,520,000. One workstream must own it and the other must drop the claim. This document's stake in it: SYN-T1: Terminate the external analytics vendor contracts and platforms ($1,520,000). +* **[MEDIUM] Workstream synergy claims exceed the deal target.** Workstreams claim $5,150,000 in total against a deal target of $4,600,000 — an excess of $550,000 (12%). Either the target is stale or some claims are double-counted. Reconcile before the tracker goes to steering. This document's stake in it: claims $1,520,000. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/03-day-one-checklist.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/03-day-one-checklist.md new file mode 100644 index 00000000..c29fba0f --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/03-day-one-checklist.md @@ -0,0 +1,21 @@ +# Day-One Readiness Checklist — Project Northstar + +Close is expected on 2026-03-01. Every item below must be true on that date. + +| ID | Item | Owner | Status | Depends on | +| --- | --- | --- | --- | --- | +| DAY-FIN-1 | Opening balance sheet signed off by both CFOs | Finance & Accounting | in progress | — | +| DAY-FIN-2 | Payment runs for Northwind suppliers confirmed for week one | Finance & Accounting | not started | — | +| DAY-HR-1 | Every Northwind employee has a signed transfer letter | People & Organisation | in progress | — | +| DAY-HR-2 | All staff paid correctly from the acquirer payroll system | People & Organisation | not started | DEP-PAY | +| DAY-COM-1 | Top 20 customers have received a named-contact letter | Commercial & Customer | in progress | — | +| DAY-COM-2 | No customer contract lapses in the first 30 days | Commercial & Customer | not started | — | +| DAY-TEC-1 | All Northwind staff can authenticate against acquirer SSO | Technology & Data | not started | DEP-SSO | +| DAY-TEC-2 | Forecasting platform reachable from Meridian networks | Technology & Data | in progress | — | + +## Items at Risk + +These items depend on work that is not scheduled to complete before close: + +* **DAY-HR-2** — All staff paid correctly from the acquirer payroll system. Depends on DEP-PAY (Payroll cutover from Workday to acquirer HRIS), owned by people, not due until month 2. +* **DAY-TEC-1** — All Northwind staff can authenticate against acquirer SSO. Depends on DEP-SSO (Single sign-on extended to all Northwind staff), owned by technology, not due until month 1. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/04-synergy-tracker.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/04-synergy-tracker.md new file mode 100644 index 00000000..c2d8292a --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/04-synergy-tracker.md @@ -0,0 +1,31 @@ +# Synergy Tracker — Project Northstar + +**Deal target:** $4,600,000 run-rate by month 12 + +**Claimed by workstreams:** $5,150,000 + +**Variance:** $550,000 (over-committed) + +## By Workstream + +| Workstream | Claimed | Share of target | +| --- | --- | --- | +| Finance & Accounting | $2,420,000 | 53% | +| People & Organisation | $310,000 | 7% | +| Commercial & Customer | $900,000 | 20% | +| Technology & Data | $1,520,000 | 33% | + +## All Claims + +| ID | Owner | Description | Value | Month | +| --- | --- | --- | --- | --- | +| SYN-F2 | finance | Terminate external analytics vendor contracts | $1,800,000 | 6 | +| SYN-T1 | technology | Terminate the external analytics vendor contracts and platforms | $1,520,000 | 7 | +| SYN-C1 | commercial | Cross-sell forecasting module to existing Meridian accounts | $900,000 | 12 | +| SYN-F1 | finance | Retire NetSuite licences and consolidate onto acquirer ERP | $620,000 | 8 | +| SYN-P1 | people | Remove duplicate HR tooling after Workday retirement | $310,000 | 9 | + +## Open Cross-Workstream Conflicts + +* **[HIGH] finance and technology both claim the same synergy.** finance claims SYN-F2 (Terminate external analytics vendor contracts, $1,800,000) and technology claims SYN-T1 (Terminate the external analytics vendor contracts and platforms, $1,520,000). These describe the same saving, so the integration total is overstated by up to $1,520,000. One workstream must own it and the other must drop the claim. +* **[MEDIUM] Workstream synergy claims exceed the deal target.** Workstreams claim $5,150,000 in total against a deal target of $4,600,000 — an excess of $550,000 (12%). Either the target is stale or some claims are double-counted. Reconcile before the tracker goes to steering. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/05-communications-pack.md b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/05-communications-pack.md new file mode 100644 index 00000000..c7d2e5b4 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/build/exported/05-communications-pack.md @@ -0,0 +1,31 @@ +# Communications Pack — Project Northstar + +## 1. Announcement Summary + +Meridian Retail Group has agreed to acquire Northwind Analytics LLC, with completion expected on 2026-03-01. + +Meridian is acquiring Northwind to bring demand forecasting in-house. Northwind's modelling team and its forecasting platform replace three external analytics vendors Meridian currently retains, and the combined customer data set is expected to improve markdown accuracy across Meridian's seasonal ranges. + +## 2. Audiences and Owners + +| Audience | Owner | First contact | +| --- | --- | --- | +| All Northwind staff | People & Organisation | Day one | +| Top 20 customers | Commercial & Customer | Day one | +| Remaining customers | Commercial & Customer | Week one | +| Suppliers on notice periods | Finance & Accounting | Week one | +| Regulators, where applicable | Finance & Accounting | Per timetable | + +## 3. Questions We Can Answer Today + +* **Will there be redundancies?** No decisions have been taken. The 140 staff at Northwind Analytics LLC transfer on existing terms. +* **Do working patterns change?** Not at close. Northwind runs a four-day week in Stockholm. +* **Which systems are changing?** See the charter's target profile table. Nothing changes for users before the relevant workstream confirms cutover. + +## 4. Questions We Cannot Answer Yet + +Being straight about this is the point of the section. Saying "we do not know yet, and here is when we will" survives contact with an audience; a confident answer that turns out to be wrong does not. + +* Final organisational design below workstream-lead level. +* Benefits harmonisation detail, pending the People workstream review. +* Long-term location strategy. diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/deals/northstar.yaml b/use-cases/Gyan0309/post-merger-integration-playbook/deals/northstar.yaml new file mode 100644 index 00000000..3dc654f7 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/deals/northstar.yaml @@ -0,0 +1,157 @@ +# Project Northstar — a synthetic post-merger integration. +# +# Fictional companies, fabricated numbers. The brief expects exactly this. +# +# The conflicts below are planted on purpose, one per comparator, because a detector +# that has never been observed to fire is not known to work: +# +# duplicate_synergy finance SYN-F2 and technology SYN-T1 are the same saving +# contested_dependency DEP-CRM is owned by both commercial and technology +# synergy_over_commitment claims total $5.15M against a $4.6M target +# unmet_day_one DAY-HR-2 needs DEP-PAY, which lands in month 2 +# +# A clean deal to contrast against lives in northstar-clean.yaml. + +deal: + code_name: Northstar + acquirer: Meridian Retail Group + target: Northwind Analytics LLC + announced: 2026-01-15 + expected_close: 2026-03-01 + integration_horizon_months: 12 + deal_value_usd: 48000000 + synergy_target_usd: 4600000 + rationale: > + Meridian is acquiring Northwind to bring demand forecasting in-house. Northwind's + modelling team and its forecasting platform replace three external analytics + vendors Meridian currently retains, and the combined customer data set is expected + to improve markdown accuracy across Meridian's seasonal ranges. + +target_profile: + headcount: 140 + locations: + - Stockholm + - Manchester + systems: + - name: Salesforce + function: commercial + disposition: migrate to acquirer instance + - name: Workday + function: hr + disposition: retire, migrate to acquirer HRIS + - name: Snowflake + function: technology + disposition: retain, consolidate accounts + - name: NetSuite + function: finance + disposition: retire, migrate to acquirer ERP + notes: > + Northwind runs a four-day week in Stockholm. Two of the three analytics vendors + being replaced are on contracts with 90-day termination notice. + +workstreams: + - key: finance + name: Finance & Accounting + lead: A. Lindqvist + scope: > + Chart of accounts, ERP migration, statutory reporting, vendor contract + rationalisation and the synergy tracker itself. + synergies: + - id: SYN-F1 + description: Retire NetSuite licences and consolidate onto acquirer ERP + annual_value_usd: 620000 + realisation_month: 8 + - id: SYN-F2 + description: Terminate external analytics vendor contracts + annual_value_usd: 1800000 + realisation_month: 6 + dependencies: + - id: DEP-COA + description: Single chart of accounts agreed and loaded + needed_by_month: 3 + needed_by: [commercial, technology] + day_one: + - id: DAY-FIN-1 + description: Opening balance sheet signed off by both CFOs + status: in_progress + - id: DAY-FIN-2 + description: Payment runs for Northwind suppliers confirmed for week one + + - key: people + name: People & Organisation + lead: R. Osei + scope: > + Employment transfer, payroll, benefits harmonisation, retention of the + modelling team, and the Stockholm four-day-week question. + synergies: + - id: SYN-P1 + description: Remove duplicate HR tooling after Workday retirement + annual_value_usd: 310000 + realisation_month: 9 + dependencies: + - id: DEP-PAY + description: Payroll cutover from Workday to acquirer HRIS + needed_by_month: 2 + needed_by: [finance] + day_one: + - id: DAY-HR-1 + description: Every Northwind employee has a signed transfer letter + status: in_progress + # Planted: this is a day-one item resting on DEP-PAY, which is month 2. + - id: DAY-HR-2 + description: All staff paid correctly from the acquirer payroll system + depends_on: [DEP-PAY] + + - key: commercial + name: Commercial & Customer + lead: M. Duarte + scope: > + Customer communications, contract novation, CRM migration, and protecting + revenue through the transition. + synergies: + - id: SYN-C1 + description: Cross-sell forecasting module to existing Meridian accounts + annual_value_usd: 900000 + realisation_month: 12 + dependencies: + # Planted: technology owns a near-identical dependency below. + - id: DEP-CRM + description: Migrate Northwind CRM records into the acquirer Salesforce instance + needed_by_month: 4 + needed_by: [people] + day_one: + - id: DAY-COM-1 + description: Top 20 customers have received a named-contact letter + status: in_progress + - id: DAY-COM-2 + description: No customer contract lapses in the first 30 days + + - key: technology + name: Technology & Data + lead: S. Bhattacharya + scope: > + Platform integration, Snowflake consolidation, access management, and + decommissioning the retired systems. + synergies: + # Planted: this is SYN-F2 under another name, claimed by a second workstream. + - id: SYN-T1 + description: Terminate the external analytics vendor contracts and platforms + annual_value_usd: 1520000 + realisation_month: 7 + dependencies: + # Planted: same work as DEP-CRM above, owned by a second workstream. + - id: DEP-CRM-MIGRATION + description: Migrate CRM records from Northwind into acquirer Salesforce + needed_by_month: 4 + needed_by: [commercial] + - id: DEP-SSO + description: Single sign-on extended to all Northwind staff + needed_by_month: 1 + needed_by: [people, commercial] + day_one: + - id: DAY-TEC-1 + description: All Northwind staff can authenticate against acquirer SSO + depends_on: [DEP-SSO] + - id: DAY-TEC-2 + description: Forecasting platform reachable from Meridian networks + status: in_progress diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/docs/screenshot.png b/use-cases/Gyan0309/post-merger-integration-playbook/docs/screenshot.png new file mode 100644 index 00000000..4ad337b6 Binary files /dev/null and b/use-cases/Gyan0309/post-merger-integration-playbook/docs/screenshot.png differ diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/__init__.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/__init__.py new file mode 100644 index 00000000..2a4e9c98 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/__init__.py @@ -0,0 +1,6 @@ +"""Post-merger integration playbook document set, built on SuperDocs. + +Detection and rendering are deterministic and offline; SuperDocs does the editing. +""" + +__version__ = "0.1.0" diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/cli.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/cli.py new file mode 100644 index 00000000..c508d052 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/cli.py @@ -0,0 +1,157 @@ +"""The driver: deal spec in, reviewed document set out. + + python -m imo.cli deals/northstar.yaml --out build/ + +Stages, in order: + + 1. load the deal spec, validated + 2. detect cross-workstream conflicts, deterministically, no model + 3. render the document set from the spec, deterministically, no model + 4. upload each document into its own SuperDocs session + 5. flag each conflict into every document that has a stake in it + 6. review per-item approval of the proposed changes + 7. export the finished set + +Stages 1–3 need no API key and no network, which means the whole detection and +rendering half of this build is testable offline. Only 4–7 touch SuperDocs. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import uuid +from pathlib import Path + +from imo.conflicts import detect, summarise +from imo.deal import DealSpecError, load_deal +from imo.documents import flag_conflicts_in_document, plan_fan_out, render_all +from imo.superdocs import SuperDocsClient, SuperDocsError + +logger = logging.getLogger("imo") + + +def configure_logging(verbose: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(message)s", + stream=sys.stdout, + ) + logging.getLogger("httpx").setLevel(logging.WARNING) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Generate a post-merger integration document set.") + parser.add_argument("deal", type=Path, help="path to a deal YAML spec") + parser.add_argument("--out", type=Path, default=Path("build"), help="output directory") + parser.add_argument( + "--offline", + action="store_true", + help="render and detect only; do not contact SuperDocs. Needs no API key.", + ) + parser.add_argument( + "--session-prefix", + default=None, + help="prefix for SuperDocs session ids (default: a fresh random one)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args(argv) + + configure_logging(args.verbose) + + # -- 1. load ----------------------------------------------------------- + try: + deal = load_deal(args.deal) + except DealSpecError as exc: + print(f"deal spec is invalid: {exc}", file=sys.stderr) + return 2 + + print(f"\nProject {deal.code_name}: {deal.acquirer} acquires {deal.target}") + print(f" {len(deal.workstreams)} workstreams, close {deal.expected_close}") + + # -- 2. detect --------------------------------------------------------- + conflicts = detect(deal) + stats = summarise(conflicts) + print(f"\nConflicts detected: {stats['total']} " + f"({stats['high']} high, {stats['medium']} medium) " + f"across {stats['workstreams_affected']} workstreams") + for conflict in conflicts: + print(f" [{conflict.severity}] {conflict.summary}") + print(f" surfaced in: {', '.join(conflict.affects)}") + + # -- 3. render --------------------------------------------------------- + args.out.mkdir(parents=True, exist_ok=True) + documents = render_all(deal) + for document in documents: + document.write(args.out) + print(f"\nRendered {len(documents)} documents into {args.out}/") + + fan_out = plan_fan_out(deal, conflicts) + if fan_out: + print("\nEach conflict lands in every document with a stake in it:") + for key, items in sorted(fan_out.items()): + print(f" {key:<26} {len(items)} conflict(s)") + + if args.offline: + print("\n--offline: stopping before SuperDocs. Documents and detection are done.") + return 0 + + # -- 4..7 -------------------------------------------------------------- + try: + client = SuperDocsClient() + except SuperDocsError as exc: + print(f"\n{exc}", file=sys.stderr) + print("Run with --offline to render and detect without an API key.", file=sys.stderr) + return 3 + + before = client.ops_remaining() + prefix = args.session_prefix or f"{deal.code_name.lower()}-{uuid.uuid4().hex[:6]}" + by_key = {d.key: d for d in documents} + sessions: dict[str, str] = {} + + print(f"\nUploading to SuperDocs (session prefix {prefix})") + for document in documents: + session_id = f"{prefix}-{document.key}" + client.upload(args.out / document.filename, session_id=session_id) + sessions[document.key] = session_id + print(f" {document.filename}") + + print("\nFlagging conflicts through SuperDocs") + outcomes = [] + for key, items in sorted(fan_out.items()): + document = by_key.get(key) + if document is None: + continue + audience = key.removeprefix("workstream-") + outcomes += flag_conflicts_in_document( + client, + session_id=sessions[key], + document_key=key, + audience=audience, + conflicts=items, + ) + + landed = sum(1 for o in outcomes if o.landed) + print(f" {landed}/{len(outcomes)} conflict flags landed") + + print("\nExporting") + exports = args.out / "exported" + exports.mkdir(exist_ok=True) + for document in documents: + client.export( + sessions[document.key], + fmt="markdown", + destination=exports / document.filename, + ) + print(f" {len(documents)} documents into {exports}/") + + after = client.ops_remaining() + if before is not None and after is not None: + print(f"\nOperations spent: {before - after} ({after} remaining)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/conflicts.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/conflicts.py new file mode 100644 index 00000000..b13fdc57 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/conflicts.py @@ -0,0 +1,343 @@ +"""Cross-workstream conflict detection. + +The card asks for one thing above all: *"Where two workstreams claim the same synergy or +the same dependency, the conflict is flagged in both documents rather than silently +duplicated."* And it says a strong build has *"cross-workstream conflicts genuinely +detected and surfaced in both places."* + +Two words in that carry the weight. + +**Genuinely** — so detection is deterministic arithmetic over the deal spec, not a model +asked whether anything looks off. A model asked that question will find something on a +clean deal and miss something on a dirty one, and neither failure is visible. Every +comparator here is exhaustive, cheap, and testable with no API key at all. + +**Both** — so a conflict is never reported once, in the master, where each workstream +lead can assume it belongs to someone else. It lands in every document that has a stake +in it. `Conflict.affects` is the list of workstreams that must each see it, and it is +what the document layer fans out over. + +Four comparators, each a general mechanism rather than a special case: + + duplicate_synergy two workstreams bank the same saving + contested_dependency two workstreams own the same deliverable + synergy_over_commitment claimed savings exceed what the deal promised + unmet_day_one a day-one item needs something not delivered until later +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from decimal import Decimal + +from imo.deal import Deal + +# Words too common in integration language to signal a shared claim on their own. +# Without this, "Consolidate finance systems" and "Consolidate HR systems" read as the +# same synergy, and the report fills with pairs nobody believes — at which point the +# real duplicates stop being read too. +STOPWORDS = { + "the", "a", "an", "and", "or", "of", "to", "for", "in", "on", "with", "from", + "consolidate", "reduce", "improve", "integrate", "align", "review", "single", + "cost", "costs", "saving", "savings", "spend", "across", "into", "by", +} + +# How much of the significant vocabulary two descriptions must share before they are +# treated as claims on the same thing. Tuned so the pairs it surfaces are ones a human +# would also call duplicates; raise it if a corpus is noisy, lower it to cast wider. +SIMILARITY_THRESHOLD = 0.6 + + +@dataclass(frozen=True) +class Conflict: + """A contradiction between workstreams, with the evidence to judge it. + + `affects` is the point of the whole module: it names every workstream whose + document must carry this conflict. A conflict flagged once centrally is a conflict + each lead assumes belongs to someone else. + """ + + kind: str + severity: str + summary: str + detail: str + affects: list[str] # workstream keys, each of which must show this + evidence: dict[str, str] = field(default_factory=dict) + + @property + def id(self) -> str: + stem = re.sub(r"[^a-z0-9]+", "-", self.summary.lower()).strip("-") + return f"{self.kind}:{stem[:48]}" + + +def _significant_words(text: str) -> set[str]: + words = re.findall(r"[a-z]{3,}", text.lower()) + return {w for w in words if w not in STOPWORDS} + + +def _similarity(a: str, b: str) -> float: + """Jaccard overlap of significant words. + + Deliberately crude and deliberately explainable. A reviewer asking "why were these + flagged as the same?" gets an answer they can check by reading, which matters more + here than a cleverer metric nobody can audit. + """ + left, right = _significant_words(a), _significant_words(b) + if not left or not right: + return 0.0 + return len(left & right) / len(left | right) + + +# --------------------------------------------------------------------------- +# 1. Two workstreams banking the same saving +# --------------------------------------------------------------------------- + + +def find_duplicate_synergies(deal: Deal) -> list[Conflict]: + """Double-counted synergy — the expensive kind of duplicate. + + Two workstreams each claiming a saving means the deal's total is overstated by the + smaller of the two, and nobody notices until the run-rate misses. Caught two ways: + an identical synergy id, and near-identical descriptions under different ids. + """ + conflicts: list[Conflict] = [] + synergies = deal.all_synergies + + for i, first in enumerate(synergies): + for second in synergies[i + 1 :]: + if first.owner == second.owner: + continue # one workstream's own bookkeeping, not a cross-claim + + same_id = first.id == second.id + similar = _similarity(first.description, second.description) >= SIMILARITY_THRESHOLD + if not (same_id or similar): + continue + + overlap = min(first.annual_value_usd, second.annual_value_usd) + conflicts.append( + Conflict( + kind="duplicate_synergy", + severity="high", + summary=( + f"{first.owner} and {second.owner} both claim the same synergy" + ), + detail=( + f"{first.owner} claims {first.id} " + f"({first.description}, {first.value_display}) and " + f"{second.owner} claims {second.id} " + f"({second.description}, {second.value_display}). " + f"These describe the same saving, so the integration total is " + f"overstated by up to ${overlap:,.0f}. One workstream must own " + f"it and the other must drop the claim." + ), + affects=[first.owner, second.owner], + evidence={ + first.owner: f"{first.id}: {first.description} ({first.value_display})", + second.owner: f"{second.id}: {second.description} ({second.value_display})", + }, + ) + ) + + return conflicts + + +# --------------------------------------------------------------------------- +# 2. Two workstreams owning the same deliverable +# --------------------------------------------------------------------------- + + +def find_contested_dependencies(deal: Deal) -> list[Conflict]: + """Two owners is the same failure as no owner, and harder to see. + + Each lead reports the item as covered, the steering pack shows it green, and it is + delivered twice or not at all. + """ + conflicts: list[Conflict] = [] + dependencies = deal.all_dependencies + + for i, first in enumerate(dependencies): + for second in dependencies[i + 1 :]: + if first.owner == second.owner: + continue + + same_id = first.id == second.id + similar = _similarity(first.description, second.description) >= SIMILARITY_THRESHOLD + if not (same_id or similar): + continue + + conflicts.append( + Conflict( + kind="contested_dependency", + severity="high", + summary=( + f"{first.owner} and {second.owner} both own the same dependency" + ), + detail=( + f"{first.owner} owns {first.id} ({first.description}, needed by " + f"month {first.needed_by_month}) and {second.owner} owns " + f"{second.id} ({second.description}, needed by month " + f"{second.needed_by_month}). Two owners means each side can " + f"reasonably assume the other is delivering it. Assign one." + ), + affects=[first.owner, second.owner], + evidence={ + first.owner: f"{first.id}: {first.description} (month {first.needed_by_month})", + second.owner: f"{second.id}: {second.description} (month {second.needed_by_month})", + }, + ) + ) + + return conflicts + + +# --------------------------------------------------------------------------- +# 3. Claimed savings exceeding what the deal promised +# --------------------------------------------------------------------------- + + +def find_synergy_over_commitment(deal: Deal) -> list[Conflict]: + """Workstream claims summing past the deal target. + + Not automatically wrong — over-programming against a target is a deliberate tactic — + but it is always a decision someone should have made on purpose, and it is exactly + what nobody notices until the tracker is assembled. + """ + claimed = deal.claimed_synergy_total + target = deal.synergy_target_usd + + if claimed <= target: + return [] + + excess = claimed - target + + # A target of zero is a real state, not a broken one: an early-stage deal has + # workstreams estimating savings before the target is agreed. Expressing the excess + # as a percentage of zero raised DivisionByZero and took the whole detection pass + # down — a crash on the most ordinary early input there is. + proportion = f" ({excess / target:.0%})" if target > 0 else "" + against = ( + f"a deal target of ${target:,.0f}" + if target > 0 + else "no agreed deal target" + ) + + return [ + Conflict( + kind="synergy_over_commitment", + severity="medium", + summary="Workstream synergy claims exceed the deal target", + detail=( + f"Workstreams claim ${claimed:,.0f} in total against {against} — " + f"an excess of ${excess:,.0f}{proportion}. Either the target is stale " + f"or some claims are double-counted. Reconcile before the tracker goes " + f"to steering." + ), + # Every workstream with a claim shares this one: it cannot be resolved by + # any single lead, and showing it only in the master lets all of them + # assume it is someone else's number that is wrong. + affects=sorted({s.owner for s in deal.all_synergies}), + evidence={ + w.key: f"claims ${w.synergy_total:,.0f}" + for w in deal.workstreams + if w.synergies + }, + ) + ] + + +# --------------------------------------------------------------------------- +# 4. Day-one items resting on things that do not exist yet +# --------------------------------------------------------------------------- + + +def find_unmet_day_one(deal: Deal) -> list[Conflict]: + """A day-one item depending on something delivered in month three is not a day-one + item — it is a month-three item nobody has rescheduled. + + This is what makes the checklist specific to *this* deal rather than a generic + list: the finding exists only because of how these particular dependencies are + sequenced. + """ + conflicts: list[Conflict] = [] + + for item in deal.all_day_one: + for dependency_id in item.depends_on: + dependency = deal.dependency(dependency_id) + if dependency is None or dependency.needed_by_month <= 0: + continue + + affects = sorted({item.owner, dependency.owner}) + conflicts.append( + Conflict( + kind="unmet_day_one", + severity="high", + summary=( + f"Day-one item {item.id} depends on work not delivered until " + f"month {dependency.needed_by_month}" + ), + detail=( + f"{item.owner} lists {item.id} ({item.description}) as a day-one " + f"readiness item, but it depends on {dependency.id} " + f"({dependency.description}), which {dependency.owner} does not " + f"deliver until month {dependency.needed_by_month}. The item " + f"cannot be green at close. Either pull the dependency forward " + f"or move the item out of day one." + ), + affects=affects, + evidence={ + item.owner: f"{item.id}: {item.description} (day one)", + dependency.owner: ( + f"{dependency.id}: {dependency.description} " + f"(month {dependency.needed_by_month})" + ), + }, + ) + ) + + return conflicts + + +# --------------------------------------------------------------------------- + +COMPARATORS = ( + find_duplicate_synergies, + find_contested_dependencies, + find_synergy_over_commitment, + find_unmet_day_one, +) + + +def detect(deal: Deal) -> list[Conflict]: + """Run every comparator, deduplicated and ordered by severity. + + An empty list is a first-class outcome: a clean deal genuinely has no + cross-workstream conflicts, and reporting a reassuring nothing is more useful than + manufacturing something to justify the check having run. + """ + seen: set[str] = set() + found: list[Conflict] = [] + + for comparator in COMPARATORS: + for conflict in comparator(deal): + if conflict.id in seen: + continue + seen.add(conflict.id) + found.append(conflict) + + rank = {"high": 0, "medium": 1, "low": 2} + return sorted(found, key=lambda c: (rank.get(c.severity, 3), c.summary)) + + +def conflicts_for(conflicts: list[Conflict], workstream_key: str) -> list[Conflict]: + """Every conflict a given workstream's document must carry.""" + return [c for c in conflicts if workstream_key in c.affects] + + +def summarise(conflicts: list[Conflict]) -> dict[str, int | Decimal]: + return { + "total": len(conflicts), + "high": sum(1 for c in conflicts if c.severity == "high"), + "medium": sum(1 for c in conflicts if c.severity == "medium"), + "workstreams_affected": len({w for c in conflicts for w in c.affects}), + } diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/deal.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/deal.py new file mode 100644 index 00000000..a880a9e6 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/deal.py @@ -0,0 +1,318 @@ +"""The deal specification — the single source of truth for a whole document set. + +Every document this build produces is a *projection* of the deal spec: the integration +charter, each workstream charter, the day-one checklists, the synergy tracker and the +communications pack all say things that trace back to a field here. + +That is the load-bearing decision, and it is what makes the card's hardest requirement +achievable at all. "Master-to-workstream consistency holds after edits made on either +side" is impossible to guarantee between N documents that merely agreed when they were +written — each edit is a chance to drift, and there is no arbiter. With a spec in the +middle, consistency is a comparison against one artifact rather than N-squared +comparisons between documents, and an edit on either side is reconciled the same way. + +The spec is YAML because a new deal, a new workstream, or a new synergy must be a data +change and never a code change. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from pathlib import Path +from typing import Any + +import yaml + + +class DealSpecError(ValueError): + """The deal file is malformed. + + Raised at load time with the offending field named. A spec that is silently + half-read produces a document set that is confidently wrong, which is far worse + than refusing to start. + """ + + +@dataclass(frozen=True) +class Synergy: + """A claimed saving or revenue gain, owned by exactly one workstream.""" + + id: str + description: str + annual_value_usd: Decimal + realisation_month: int + owner: str # workstream key + + @property + def value_display(self) -> str: + return f"${self.annual_value_usd:,.0f}" + + +@dataclass(frozen=True) +class Dependency: + """Something a workstream needs, or provides, before work can proceed.""" + + id: str + description: str + owner: str # workstream key that OWNS delivery + needed_by_month: int + needed_by: list[str] = field(default_factory=list) # workstream keys that consume it + + +@dataclass(frozen=True) +class DayOneItem: + """A readiness item that must be true on day one. + + `depends_on` is what makes the day-one checklist deal-specific rather than generic: + an item that relies on a dependency not delivered until month three is a real + finding about *this* deal, not a template line. + """ + + id: str + description: str + owner: str + depends_on: list[str] = field(default_factory=list) # dependency ids + status: str = "not_started" + + +@dataclass(frozen=True) +class Workstream: + key: str + name: str + lead: str + scope: str + synergies: list[Synergy] = field(default_factory=list) + dependencies: list[Dependency] = field(default_factory=list) + day_one: list[DayOneItem] = field(default_factory=list) + + @property + def synergy_total(self) -> Decimal: + return sum((s.annual_value_usd for s in self.synergies), Decimal(0)) + + +@dataclass(frozen=True) +class TargetProfile: + headcount: int + locations: list[str] + systems: list[dict[str, str]] + notes: str = "" + + +@dataclass(frozen=True) +class Deal: + code_name: str + acquirer: str + target: str + announced: date + expected_close: date + integration_horizon_months: int + deal_value_usd: Decimal + synergy_target_usd: Decimal + rationale: str + profile: TargetProfile + workstreams: list[Workstream] + + # -- derived --------------------------------------------------------------- + + @property + def all_synergies(self) -> list[Synergy]: + return [s for w in self.workstreams for s in w.synergies] + + @property + def all_dependencies(self) -> list[Dependency]: + return [d for w in self.workstreams for d in w.dependencies] + + @property + def all_day_one(self) -> list[DayOneItem]: + return [i for w in self.workstreams for i in w.day_one] + + @property + def claimed_synergy_total(self) -> Decimal: + return sum((s.annual_value_usd for s in self.all_synergies), Decimal(0)) + + def workstream(self, key: str) -> Workstream | None: + return next((w for w in self.workstreams if w.key == key), None) + + def dependency(self, dependency_id: str) -> Dependency | None: + return next((d for d in self.all_dependencies if d.id == dependency_id), None) + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def _require(mapping: dict[str, Any], key: str, where: str) -> Any: + if key not in mapping: + raise DealSpecError(f"{where}: missing required field {key!r}") + return mapping[key] + + +def _as_date(value: Any, where: str) -> date: + if isinstance(value, date): + return value + raise DealSpecError( + f"{where}: expected a date (YYYY-MM-DD), got {value!r}. " + f"Quote it and it becomes a string, which is not the same thing." + ) + + +def _as_money(value: Any, where: str) -> Decimal: + try: + return Decimal(str(value).replace(",", "").replace("$", "")) + except Exception as exc: + raise DealSpecError(f"{where}: {value!r} is not a number") from exc + + +def load_deal(path: Path) -> Deal: + """Load and validate a deal spec. + + Validation is thorough and happens here rather than at use time, because the + failure mode of a half-valid spec is a complete, plausible, wrong document set — + and nobody reading a polished integration charter thinks to doubt the input file. + """ + # A typo'd path and a malformed file are the two commonest first interactions with + # any tool that takes one. Both used to surface as raw tracebacks, which tells the + # user the tool is broken rather than that they mistyped something. + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise DealSpecError(f"no such deal file: {path}") from None + except OSError as exc: + raise DealSpecError(f"cannot read {path}: {exc.strerror or exc}") from None + + try: + raw = yaml.safe_load(text) or {} + except yaml.YAMLError as exc: + # YAML errors carry the line and column; keep them, drop the traceback. + where = getattr(exc, "problem_mark", None) + location = f" at line {where.line + 1}, column {where.column + 1}" if where else "" + problem = getattr(exc, "problem", None) or str(exc).splitlines()[0] + raise DealSpecError(f"{path.name} is not valid YAML{location}: {problem}") from None + + if not isinstance(raw, dict): + raise DealSpecError( + f"{path.name}: expected a mapping at the top level, got " + f"{type(raw).__name__}" + ) + + deal_block = _require(raw, "deal", path.name) + profile_block = _require(raw, "target_profile", path.name) + workstream_blocks = _require(raw, "workstreams", path.name) + + if not workstream_blocks: + raise DealSpecError(f"{path.name}: a deal needs at least one workstream") + + profile = TargetProfile( + headcount=int(_require(profile_block, "headcount", "target_profile")), + locations=list(profile_block.get("locations") or []), + systems=list(profile_block.get("systems") or []), + notes=profile_block.get("notes", ""), + ) + + workstreams: list[Workstream] = [] + seen_keys: set[str] = set() + + for index, block in enumerate(workstream_blocks): + where = f"{path.name} workstream #{index + 1}" + key = _require(block, "key", where) + + if key in seen_keys: + raise DealSpecError(f"{where}: duplicate workstream key {key!r}") + seen_keys.add(key) + + synergies = [ + Synergy( + id=_require(s, "id", f"{where} synergy"), + description=_require(s, "description", f"{where} synergy"), + annual_value_usd=_as_money( + _require(s, "annual_value_usd", f"{where} synergy"), f"{where} synergy" + ), + realisation_month=int(s.get("realisation_month", 12)), + owner=key, + ) + for s in (block.get("synergies") or []) + ] + + dependencies = [ + Dependency( + id=_require(d, "id", f"{where} dependency"), + description=_require(d, "description", f"{where} dependency"), + owner=key, + needed_by_month=int(d.get("needed_by_month", 0)), + needed_by=list(d.get("needed_by") or []), + ) + for d in (block.get("dependencies") or []) + ] + + day_one = [ + DayOneItem( + id=_require(i, "id", f"{where} day_one"), + description=_require(i, "description", f"{where} day_one"), + owner=key, + depends_on=list(i.get("depends_on") or []), + status=i.get("status", "not_started"), + ) + for i in (block.get("day_one") or []) + ] + + workstreams.append( + Workstream( + key=key, + name=_require(block, "name", where), + lead=block.get("lead", "unassigned"), + scope=block.get("scope", ""), + synergies=synergies, + dependencies=dependencies, + day_one=day_one, + ) + ) + + deal = Deal( + code_name=_require(deal_block, "code_name", "deal"), + acquirer=_require(deal_block, "acquirer", "deal"), + target=_require(deal_block, "target", "deal"), + announced=_as_date(_require(deal_block, "announced", "deal"), "deal.announced"), + expected_close=_as_date( + _require(deal_block, "expected_close", "deal"), "deal.expected_close" + ), + integration_horizon_months=int(deal_block.get("integration_horizon_months", 12)), + deal_value_usd=_as_money(_require(deal_block, "deal_value_usd", "deal"), "deal"), + synergy_target_usd=_as_money( + _require(deal_block, "synergy_target_usd", "deal"), "deal" + ), + rationale=deal_block.get("rationale", ""), + profile=profile, + workstreams=workstreams, + ) + + if deal.expected_close < deal.announced: + raise DealSpecError( + f"{path.name}: expected_close ({deal.expected_close}) is before " + f"announced ({deal.announced})" + ) + + # Referential integrity, checked once at load. A day-one item pointing at a + # dependency that does not exist would otherwise surface as a confidently generated + # checklist entry with no basis, which is the kind of error nobody catches by reading. + known_dependencies = {d.id for d in deal.all_dependencies} + for item in deal.all_day_one: + for needed in item.depends_on: + if needed not in known_dependencies: + raise DealSpecError( + f"{path.name}: day-one item {item.id!r} depends on {needed!r}, " + f"which no workstream declares" + ) + + known_workstreams = {w.key for w in deal.workstreams} + for dependency in deal.all_dependencies: + for consumer in dependency.needed_by: + if consumer not in known_workstreams: + raise DealSpecError( + f"{path.name}: dependency {dependency.id!r} is needed by " + f"{consumer!r}, which is not a workstream" + ) + + return deal diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/documents.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/documents.py new file mode 100644 index 00000000..90519aca --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/documents.py @@ -0,0 +1,492 @@ +"""Rendering the document set, and flagging conflicts into it through SuperDocs. + +Two stages, deliberately separated. + +**Rendering is deterministic.** Every document is generated from the deal spec by plain +Python. No model is involved, because a charter is a projection of known facts and a +model asked to write one will quietly improve a number. Deterministic rendering also +means the same deal always produces the same bytes, so a diff between two runs shows +only what actually changed in the deal. + +**Flagging is SuperDocs' job.** Once the documents exist, the conflicts detected in +`conflicts.py` are applied *as edits to the real documents*, through the chat and +approval surface. That is the product doing the work it exists to do: a targeted change +landing in place, reviewable, with everything else untouched. + +The card's requirement — *"the conflict is flagged in both documents rather than +silently duplicated"* — is satisfied by fanning one conflict out over every workstream +in `Conflict.affects`, so each lead sees it in their own charter rather than assuming +it belongs to someone else. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from imo.conflicts import Conflict, conflicts_for +from imo.deal import Deal, Workstream +from imo.superdocs import ChatResult, SuperDocsClient + +logger = logging.getLogger("imo.documents") + +CONFLICT_HEADING = "Open Cross-Workstream Conflicts" + + +@dataclass(frozen=True) +class RenderedDocument: + key: str + title: str + filename: str + body: str + + def write(self, directory: Path) -> Path: + path = directory / self.filename + path.write_text(self.body, encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _money(value) -> str: + return f"${value:,.0f}" + + +def render_charter(deal: Deal) -> RenderedDocument: + """The master integration charter — the document every other one answers to.""" + lines = [ + f"# Integration Charter — Project {deal.code_name}", + "", + f"**Acquirer:** {deal.acquirer} ", + f"**Target:** {deal.target} ", + f"**Announced:** {deal.announced.isoformat()} ", + f"**Expected close:** {deal.expected_close.isoformat()} ", + f"**Integration horizon:** {deal.integration_horizon_months} months", + "", + "## 1. Deal Rationale", + "", + deal.rationale.strip(), + "", + "## 2. Synergy Target", + "", + f"The deal carries a run-rate synergy target of " + f"{_money(deal.synergy_target_usd)} by month " + f"{deal.integration_horizon_months}.", + "", + f"Workstreams currently claim {_money(deal.claimed_synergy_total)} in total.", + "", + "| Workstream | Lead | Claimed |", + "|---|---|---|", + ] + for workstream in deal.workstreams: + lines.append( + f"| {workstream.name} | {workstream.lead} | {_money(workstream.synergy_total)} |" + ) + + lines += [ + "", + "## 3. Workstreams and Scope", + "", + ] + for workstream in deal.workstreams: + lines += [ + f"### {workstream.name}", + "", + f"**Lead:** {workstream.lead}", + "", + workstream.scope.strip(), + "", + ] + + lines += [ + "## 4. Target Profile", + "", + f"{deal.target} has {deal.profile.headcount} staff across " + f"{', '.join(deal.profile.locations)}.", + "", + "| System | Function | Disposition |", + "|---|---|---|", + ] + for system in deal.profile.systems: + lines.append( + f"| {system.get('name', '?')} | {system.get('function', '?')} | " + f"{system.get('disposition', 'to be decided')} |" + ) + + if deal.profile.notes.strip(): + lines += ["", "**Notes.** " + deal.profile.notes.strip()] + + lines += [ + "", + "## 5. Governance", + "", + "The integration management office reports to the steering committee monthly. " + "Each workstream lead owns the charter for their function and is accountable " + "for the synergies claimed in it.", + "", + ] + + return RenderedDocument( + key="charter", + title=f"Integration Charter — Project {deal.code_name}", + filename="01-integration-charter.md", + body="\n".join(lines), + ) + + +def render_workstream_charter(deal: Deal, workstream: Workstream) -> RenderedDocument: + """One charter per function, consistent with the master by construction.""" + lines = [ + f"# {workstream.name} Workstream Charter — Project {deal.code_name}", + "", + f"**Lead:** {workstream.lead} ", + f"**Deal:** {deal.acquirer} acquires {deal.target} ", + f"**Expected close:** {deal.expected_close.isoformat()}", + "", + "## 1. Scope", + "", + workstream.scope.strip() or "_Scope to be confirmed._", + "", + "## 2. Synergies Owned", + "", + ] + + if workstream.synergies: + lines += ["| ID | Description | Annual value | Realised by |", "|---|---|---|---|"] + for synergy in workstream.synergies: + lines.append( + f"| {synergy.id} | {synergy.description} | " + f"{synergy.value_display} | month {synergy.realisation_month} |" + ) + lines += ["", f"**Total claimed: {_money(workstream.synergy_total)}**"] + else: + lines.append("This workstream claims no synergies directly.") + + lines += ["", "## 3. Dependencies Owned", ""] + if workstream.dependencies: + lines += ["| ID | Description | Needed by | Consumed by |", "|---|---|---|---|"] + for dependency in workstream.dependencies: + consumers = ", ".join(dependency.needed_by) or "—" + lines.append( + f"| {dependency.id} | {dependency.description} | " + f"month {dependency.needed_by_month} | {consumers} |" + ) + else: + lines.append("This workstream owns no cross-workstream dependencies.") + + lines += ["", "## 4. Day-One Readiness", ""] + if workstream.day_one: + lines += ["| ID | Item | Status | Depends on |", "|---|---|---|---|"] + for item in workstream.day_one: + depends = ", ".join(item.depends_on) or "—" + lines.append( + f"| {item.id} | {item.description} | " + f"{item.status.replace('_', ' ')} | {depends} |" + ) + else: + lines.append("No day-one items are assigned to this workstream.") + + lines += ["", f"## 5. {CONFLICT_HEADING}", "", "_None recorded._", ""] + + return RenderedDocument( + key=f"workstream-{workstream.key}", + title=f"{workstream.name} Workstream Charter", + filename=f"02-workstream-{workstream.key}.md", + body="\n".join(lines), + ) + + +def render_day_one_checklist(deal: Deal) -> RenderedDocument: + """The day-one readiness checklist. + + Specific to this deal rather than generic: every line comes from a workstream's own + declared items, and the dependency column is what lets a reader see *why* an item is + at risk. A generic checklist cannot have that column at all. + """ + lines = [ + f"# Day-One Readiness Checklist — Project {deal.code_name}", + "", + f"Close is expected on {deal.expected_close.isoformat()}. Every item below must " + f"be true on that date.", + "", + "| ID | Item | Owner | Status | Depends on |", + "|---|---|---|---|---|", + ] + + for item in deal.all_day_one: + workstream = deal.workstream(item.owner) + depends = ", ".join(item.depends_on) or "—" + lines.append( + f"| {item.id} | {item.description} | " + f"{workstream.name if workstream else item.owner} | " + f"{item.status.replace('_', ' ')} | {depends} |" + ) + + at_risk = [ + item + for item in deal.all_day_one + for dep_id in item.depends_on + if (dep := deal.dependency(dep_id)) and dep.needed_by_month > 0 + ] + + lines += ["", "## Items at Risk", ""] + if at_risk: + lines.append( + "These items depend on work that is not scheduled to complete before close:" + ) + lines.append("") + for item in at_risk: + for dep_id in item.depends_on: + dependency = deal.dependency(dep_id) + if dependency and dependency.needed_by_month > 0: + lines.append( + f"- **{item.id}** — {item.description}. Depends on " + f"{dependency.id} ({dependency.description}), owned by " + f"{dependency.owner}, not due until month " + f"{dependency.needed_by_month}." + ) + else: + lines.append("No day-one item depends on work scheduled after close.") + + lines.append("") + return RenderedDocument( + key="day-one", + title=f"Day-One Readiness Checklist — Project {deal.code_name}", + filename="03-day-one-checklist.md", + body="\n".join(lines), + ) + + +def render_synergy_tracker(deal: Deal) -> RenderedDocument: + """The synergy tracking summary, reconciled against the deal target.""" + variance = deal.claimed_synergy_total - deal.synergy_target_usd + + lines = [ + f"# Synergy Tracker — Project {deal.code_name}", + "", + f"**Deal target:** {_money(deal.synergy_target_usd)} run-rate by month " + f"{deal.integration_horizon_months} ", + f"**Claimed by workstreams:** {_money(deal.claimed_synergy_total)} ", + f"**Variance:** {_money(variance)} " + f"({'over-committed' if variance > 0 else 'under-committed' if variance else 'on target'})", + "", + "## By Workstream", + "", + "| Workstream | Claimed | Share of target |", + "|---|---|---|", + ] + for workstream in deal.workstreams: + share = ( + workstream.synergy_total / deal.synergy_target_usd + if deal.synergy_target_usd + else 0 + ) + lines.append( + f"| {workstream.name} | {_money(workstream.synergy_total)} | {share:.0%} |" + ) + + lines += ["", "## All Claims", "", "| ID | Owner | Description | Value | Month |", "|---|---|---|---|---|"] + for synergy in sorted(deal.all_synergies, key=lambda s: -s.annual_value_usd): + lines.append( + f"| {synergy.id} | {synergy.owner} | {synergy.description} | " + f"{synergy.value_display} | {synergy.realisation_month} |" + ) + + lines += ["", f"## {CONFLICT_HEADING}", "", "_None recorded._", ""] + + return RenderedDocument( + key="synergy-tracker", + title=f"Synergy Tracker — Project {deal.code_name}", + filename="04-synergy-tracker.md", + body="\n".join(lines), + ) + + +def render_communications_pack(deal: Deal) -> RenderedDocument: + """The comms pack. Deliberately plain, and honest about what is not yet decided.""" + lines = [ + f"# Communications Pack — Project {deal.code_name}", + "", + "## 1. Announcement Summary", + "", + f"{deal.acquirer} has agreed to acquire {deal.target}, with completion expected " + f"on {deal.expected_close.isoformat()}.", + "", + deal.rationale.strip(), + "", + "## 2. Audiences and Owners", + "", + "| Audience | Owner | First contact |", + "|---|---|---|", + "| All Northwind staff | People & Organisation | Day one |", + "| Top 20 customers | Commercial & Customer | Day one |", + "| Remaining customers | Commercial & Customer | Week one |", + "| Suppliers on notice periods | Finance & Accounting | Week one |", + "| Regulators, where applicable | Finance & Accounting | Per timetable |", + "", + "## 3. Questions We Can Answer Today", + "", + f"- **Will there be redundancies?** No decisions have been taken. The " + f"{deal.profile.headcount} staff at {deal.target} transfer on existing terms.", + f"- **Do working patterns change?** Not at close. " + + ( + deal.profile.notes.strip().split(".")[0] + "." + if deal.profile.notes.strip() + else "Local arrangements continue unchanged." + ), + "- **Which systems are changing?** See the charter's target profile table. " + "Nothing changes for users before the relevant workstream confirms cutover.", + "", + "## 4. Questions We Cannot Answer Yet", + "", + "Being straight about this is the point of the section. Saying \"we do not know " + "yet, and here is when we will\" survives contact with an audience; a " + "confident answer that turns out to be wrong does not.", + "", + "- Final organisational design below workstream-lead level.", + "- Benefits harmonisation detail, pending the People workstream review.", + "- Long-term location strategy.", + "", + ] + return RenderedDocument( + key="comms-pack", + title=f"Communications Pack — Project {deal.code_name}", + filename="05-communications-pack.md", + body="\n".join(lines), + ) + + +def render_all(deal: Deal) -> list[RenderedDocument]: + """The complete document set, in the order a reader would meet it.""" + documents = [render_charter(deal)] + documents += [render_workstream_charter(deal, w) for w in deal.workstreams] + documents += [ + render_day_one_checklist(deal), + render_synergy_tracker(deal), + render_communications_pack(deal), + ] + return documents + + +# --------------------------------------------------------------------------- +# Flagging conflicts into the documents, through SuperDocs +# --------------------------------------------------------------------------- + + +def conflict_instruction(conflict: Conflict, *, audience: str) -> str: + """The edit instruction sent to SuperDocs for one conflict in one document. + + Written to be surgical on purpose: it names the exact section to change and states + that nothing else may be touched. A vague instruction gets a helpfully rewritten + document, and "helpfully rewritten" is indistinguishable from "silently damaged" + once it is three documents deep. + """ + stake = conflict.evidence.get(audience, "") + stake_line = f" This document's stake in it: {stake}." if stake else "" + + return ( + f"In the section titled '{CONFLICT_HEADING}', replace the text '_None recorded._' " + f"with a bullet describing this conflict, or append a bullet if entries already " + f"exist. The bullet must read:\n\n" + f"**[{conflict.severity.upper()}] {conflict.summary}.** {conflict.detail}" + f"{stake_line}\n\n" + f"Change nothing else in the document. Do not alter any table, any figure, or " + f"any other section." + ) + + +@dataclass +class FlagOutcome: + document_key: str + session_id: str + conflict_id: str + changes_proposed: int + approved: int + + @property + def landed(self) -> bool: + return self.approved > 0 + + +def flag_conflicts_in_document( + client: SuperDocsClient, + *, + session_id: str, + document_key: str, + audience: str, + conflicts: list[Conflict], + auto_approve: bool = True, +) -> list[FlagOutcome]: + """Apply every conflict affecting this document, one edit at a time. + + One conflict per chat turn rather than one turn listing all of them. That costs + more turns, and buys two things worth more than the saving: each proposed change is + reviewable on its own, so a human can accept one flag and reject another; and a + failure is attributable to a specific conflict instead of leaving a partly-edited + document nobody can reason about. + """ + outcomes: list[FlagOutcome] = [] + + for conflict in conflicts: + result: ChatResult = client.chat( + session_id=session_id, + message=conflict_instruction(conflict, audience=audience), + ) + + approved = 0 + if auto_approve and result.pending_changes: + client.decide_all(result) + approved = len(result.pending_changes) + + # Approving does not mean finished. The job goes on to apply the change, + # and the next instruction into a still-working session is a 409 + # `session_busy`. Waiting here is what makes driving one session in a loop + # safe at all. + client.wait_until_idle(session_id) + + logger.info( + "%s: %s -> %d change(s), %d approved", + document_key, + conflict.kind, + len(result.pending_changes), + approved, + ) + + outcomes.append( + FlagOutcome( + document_key=document_key, + session_id=session_id, + conflict_id=conflict.id, + changes_proposed=len(result.pending_changes), + approved=approved, + ) + ) + + return outcomes + + +def plan_fan_out(deal: Deal, conflicts: list[Conflict]) -> dict[str, list[Conflict]]: + """Which conflicts each document must carry. + + This mapping is the card's requirement in one function: a conflict between two + workstreams appears in *both* of their charters, and in the master, rather than + once in a central list where each lead can assume it is someone else's problem. + """ + plan: dict[str, list[Conflict]] = {} + + for workstream in deal.workstreams: + mine = conflicts_for(conflicts, workstream.key) + if mine: + plan[f"workstream-{workstream.key}"] = mine + + # The synergy tracker carries every money conflict, because that is the document + # someone reads when the numbers do not add up. + money = [c for c in conflicts if c.kind in ("duplicate_synergy", "synergy_over_commitment")] + if money: + plan["synergy-tracker"] = money + + return plan diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/imo/superdocs.py b/use-cases/Gyan0309/post-merger-integration-playbook/imo/superdocs.py new file mode 100644 index 00000000..3c639098 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/imo/superdocs.py @@ -0,0 +1,498 @@ +"""A SuperDocs API client covering the four-call contract. + +Upload, chat, approve, export. The brief is explicit that these four come first and +everything else is optional depth, so this client does those four properly rather than +covering the whole API shallowly. + +Almost every non-obvious line here exists because of something that bit during +integration, and each is commented where it bites. The full list, with reproductions, +is in BUGS.md. + +The verified flow, end to end: + + 1. POST /v1/documents/upload-base64 with session_id → persists the document + 2. GET /v1/sessions/{id}/documents → document_id ("doc_primary") + 3. POST /v1/chat/async → job_id + 4. GET /v1/jobs/{job_id} → poll; changes arrive in + metadata.pending_changes + 5. POST /v1/chat/{session_id}/approve → job_id + change_id + approved + 6. POST /v1/documents/export → session_id + format +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger("imo.superdocs") + +DEFAULT_BASE_URL = "https://api.superdocs.app/v1" + +REQUEST_TIMEOUT_SECONDS = 120.0 +POLL_INTERVAL_SECONDS = 3.0 +POLL_CEILING_SECONDS = 900.0 + +# Statuses that mean the job has stopped moving. Deliberately expressed as the set of +# *terminal* states rather than the set of working ones: the working vocabulary +# includes `in_progress`, and a poller that lists only pending/processing/running/queued +# treats `in_progress` as finished, reads a null result, and reports no changes at all. +TERMINAL_STATUSES = { + "completed", + "complete", + "succeeded", + "success", + "failed", + "error", + "cancelled", + "canceled", + "awaiting_approval", +} + +EXPORT_FORMATS = {"docx", "pdf", "html", "markdown", "txt"} + + +class SuperDocsError(RuntimeError): + """An API call failed in a way the caller cannot recover from.""" + + +@dataclass +class UploadedDocument: + session_id: str + document_id: str + durable_document_id: str | None + filename: str + chunks_count: int = 0 + version_id: str | None = None + + +@dataclass +class PendingChange: + change_id: str + operation: str + chunk_id: str | None + document_id: str | None + old_html: str + new_html: str + explanation: str + + @classmethod + def from_api(cls, raw: dict[str, Any]) -> PendingChange: + return cls( + change_id=raw["change_id"], + operation=raw.get("operation", "edit"), + chunk_id=raw.get("chunk_id"), + document_id=raw.get("document_id"), + old_html=raw.get("old_html") or "", + new_html=raw.get("new_html") or "", + explanation=raw.get("ai_explanation") or "", + ) + + +@dataclass +class ChatResult: + session_id: str + job_id: str + status: str + pending_changes: list[PendingChange] = field(default_factory=list) + response_text: str = "" + raw: dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def awaiting_approval(self) -> bool: + return self.status == "awaiting_approval" + + +def parse_embedded_json(raw: Any) -> Any: + """Second-parse a field that arrives as a JSON-encoded string. + + The brief warns that proposed-change content arrives JSON-encoded and needs a + second parse, and that missing this is the commonest cause of diff cards full of + `undefined`. Worth being precise about *where* it applies, because it is not where + you would first look: + + metadata.pending_changes[] → already objects, no second parse + metadata.intermediate_responses[] → `content` IS a JSON string for entries + of type `proposed_change_batch` + + So a client reading pending_changes is fine, and one reading the streamed + intermediate responses must parse twice. + """ + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + return raw + + +class SuperDocsClient: + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + *, + timeout: float = REQUEST_TIMEOUT_SECONDS, + ) -> None: + self._key = api_key or os.environ.get("SUPERDOCS_API_KEY", "") + if not self._key: + raise SuperDocsError( + "SUPERDOCS_API_KEY is not set. Copy .env.example to .env and add your key." + ) + self._base = ( + base_url or os.environ.get("SUPERDOCS_BASE_URL") or DEFAULT_BASE_URL + ).rstrip("/") + self._timeout = timeout + self.calls_made = 0 + + # -- plumbing ------------------------------------------------------------ + + def _redact(self, text: str) -> str: + return text.replace(self._key, "") if self._key else text + + def _request( + self, + method: str, + path: str, + *, + json_body: dict | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: + url = f"{self._base}{path}" + started = time.monotonic() + + try: + with httpx.Client(timeout=timeout or self._timeout) as client: + response = client.request( + method, + url, + headers={ + "Authorization": f"Bearer {self._key}", + "Content-Type": "application/json", + }, + json=json_body, + ) + except httpx.RequestError as exc: + raise SuperDocsError( + f"{method} {path} unreachable: {type(exc).__name__}" + ) from exc + + self.calls_made += 1 + logger.info( + "%s %s -> %s in %.1fs", + method, + path, + response.status_code, + time.monotonic() - started, + ) + + if response.status_code >= 400: + raise SuperDocsError( + f"{method} {path} -> HTTP {response.status_code}: " + f"{self._redact(response.text[:600])}" + ) + + return response.json() if response.content else {} + + # -- 1. upload ----------------------------------------------------------- + + def upload( + self, path: Path, *, session_id: str, filename: str | None = None + ) -> UploadedDocument: + """Upload a document into a session, durably. + + Two things bite here. + + **The field is `file_base64`**, not `content_base64`. The wrong name returns a + 422 that names the missing field, which is genuinely helpful. + + **Without a `session_id` the upload is a one-off conversion and is not saved.** + You get parsed HTML back and nothing is stored — and the response says so, in a + `how_to_persist` field that states the exact request which would change it. + That is the clearest piece of API self-documentation in this surface. + """ + response = self._request( + "POST", + "/documents/upload-base64", + json_body={ + "filename": filename or path.name, + "file_base64": base64.b64encode(path.read_bytes()).decode("ascii"), + "session_id": session_id, + }, + ) + + if not response.get("persisted"): + raise SuperDocsError( + f"{path.name} was not persisted despite a session_id. " + f"Server said: {response.get('how_to_persist')!r}" + ) + + # The upload response does not carry the id chat needs; that comes from the + # session's document list, where ids are session-scoped ("doc_primary") and + # separate from the durable UUID. + listed = self.list_documents(session_id) + match = next( + (d for d in listed if d.filename.startswith(Path(path.name).stem)), None + ) or (listed[-1] if listed else None) + + if match is None: + raise SuperDocsError(f"{path.name} uploaded but is not listed in the session") + + match.version_id = response.get("version_id") + match.chunks_count = response.get("chunks_count", match.chunks_count) + return match + + def list_documents(self, session_id: str) -> list[UploadedDocument]: + response = self._request("GET", f"/sessions/{session_id}/documents") + return [ + UploadedDocument( + session_id=session_id, + document_id=d["document_id"], + durable_document_id=d.get("durable_document_id"), + filename=d.get("title") or d["document_id"], + chunks_count=d.get("chunks_count", 0), + ) + for d in response.get("documents", []) + ] + + # -- 2. chat ------------------------------------------------------------- + + def chat( + self, + *, + session_id: str, + message: str, + document_id: str | None = None, + approval_mode: str = "ask_every_time", + model_tier: str | None = None, + thinking_depth: str | None = None, + ) -> ChatResult: + """Send an edit instruction and wait for the job to settle. + + Async, always — and not only because long edits exceed the sync gateway + timeout. **Sync `/v1/chat` never returns a `job_id`, and `/approve` requires + one.** So with `approval_mode: ask_every_time`, sync chat produces changes that + cannot be approved through the API at all. Async is the only path that closes + the loop. + + `session_id` is caller-chosen, which makes it an idempotency handle: re-sending + under the same session continues that conversation rather than starting a new one. + + `document_html` is deliberately never sent. The session already holds the + document; re-sending it every turn is wasteful and risks clobbering edits the + model has already made. + """ + body: dict[str, Any] = { + "session_id": session_id, + "message": message, + "approval_mode": approval_mode, + } + if document_id: + body["document_id"] = document_id + if model_tier: + body["model_tier"] = model_tier + if thinking_depth: + body["thinking_depth"] = thinking_depth + + started = self._request("POST", "/chat/async", json_body=body) + job_id = started.get("job_id") + if not job_id: + raise SuperDocsError(f"/chat/async returned no job_id: {sorted(started)}") + + return self.wait_for_job(job_id, session_id=session_id) + + def wait_for_job(self, job_id: str, *, session_id: str) -> ChatResult: + """Poll a job until it stops moving. + + A quiet minute here is normal, not a hang — the brief is explicit that thirty + seconds to several minutes with no visible progress is still processing. The + ceiling exists so an automated run cannot block forever. + """ + deadline = time.monotonic() + POLL_CEILING_SECONDS + + while True: + job = self._request("GET", f"/jobs/{job_id}") + status = (job.get("status") or "").lower() + + if status in TERMINAL_STATUSES or job.get("error"): + break + if time.monotonic() > deadline: + raise SuperDocsError( + f"job {job_id} still {status!r} after {POLL_CEILING_SECONDS:.0f}s" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + if job.get("error"): + raise SuperDocsError(f"job {job_id} failed: {job['error']}") + + # Pending changes live in `metadata`, not in `result` — `result` stays null for + # the whole time the job is awaiting approval, which is exactly when a caller + # needs the changes in order to act on them. + metadata = job.get("metadata") or {} + raw_changes = metadata.get("pending_changes") or [] + + return ChatResult( + session_id=session_id, + job_id=job_id, + status=(job.get("status") or "").lower(), + pending_changes=[PendingChange.from_api(c) for c in raw_changes], + response_text=self._latest_response_text(metadata), + raw=job, + ) + + @staticmethod + def _latest_response_text(metadata: dict[str, Any]) -> str: + entries = metadata.get("intermediate_responses") or [] + spoken = [e for e in entries if e.get("type") == "user_facing"] + return spoken[-1].get("content", "") if spoken else "" + + # -- 3. approve ---------------------------------------------------------- + + def approve( + self, + session_id: str, + *, + job_id: str, + change_id: str | None = None, + approved: bool = True, + feedback: str | None = None, + ) -> dict[str, Any]: + """Approve or reject one proposed change. + + **`job_id` is required**, and that is not documented in the task brief — which + mentions only the top-level `approved`. Omitting either gives a 422 that does + at least name the missing fields. + + Note the field is `change_id`, singular. Batch decisions go in `changes` as + `[{change_id, approved, feedback}]`, not in a `change_ids` list. + """ + body: dict[str, Any] = {"job_id": job_id, "approved": approved} + if change_id: + body["change_id"] = change_id + if feedback: + body["feedback"] = feedback + return self._request("POST", f"/chat/{session_id}/approve", json_body=body) + + def decide_all( + self, result: ChatResult, *, verdicts: dict[str, bool] | None = None + ) -> list[dict[str, Any]]: + """Decide every pending change individually. + + Per item rather than all-or-nothing, because the point of a review is that a + reviewer can accept one change and reject another in the same pass. `verdicts` + maps change_id → approved; anything unlisted defaults to approved. + """ + verdicts = verdicts or {} + outcomes = [] + for change in result.pending_changes: + outcomes.append( + self.approve( + result.session_id, + job_id=result.job_id, + change_id=change.change_id, + approved=verdicts.get(change.change_id, True), + ) + ) + return outcomes + + def continue_session(self, session_id: str, *, job_id: str) -> dict[str, Any]: + return self._request( + "POST", f"/chat/{session_id}/continue", json_body={"job_id": job_id} + ) + + def wait_until_idle(self, session_id: str, *, timeout: float = 300.0) -> None: + """Block until the session has no active job. + + Approving returns 200 immediately, but the job is not finished at that moment — + it goes on to actually apply the change. Send the next instruction too soon and + the API answers 409 `session_busy`, which is correct behaviour and easy to trip + over when you drive a session in a loop, as this build does. + + Credit where it is due: the 409 body is one of the most useful error messages + on this API. It names the condition, lists the states that count as active, and + gives three concrete remedies. Most of this method is just doing what it says. + """ + deadline = time.monotonic() + timeout + active = {"pending", "in_progress", "processing", "running", "queued"} + + while True: + jobs = self._request("GET", f"/sessions/{session_id}/jobs").get("jobs", []) + if not any((j.get("status") or "").lower() in active for j in jobs): + return + if time.monotonic() > deadline: + raise SuperDocsError( + f"session {session_id} still has an active job after {timeout:.0f}s" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + # -- 4. export ----------------------------------------------------------- + + def export( + self, session_id: str, *, fmt: str = "markdown", destination: Path | None = None + ) -> bytes: + """Export the session's document, returning the file bytes. + + Three things differ from what the task brief's cheat-sheet says, and each is a + separate 404 or crash if you follow the sheet: + + * the path is **`/v1/documents/export`**, not `/v1/export` + * it takes a **`session_id`**, not a document id + * markdown is spelled **`markdown`**, not `md` + + And the response is **the file itself, not JSON** — a client that calls + `.json()` on it dies on the first byte. Returning bytes here rather than a dict + is the honest shape. + + Exports do not cost operations, so this is the cheap way to checkpoint work. + """ + if fmt not in EXPORT_FORMATS: + raise SuperDocsError( + f"unknown export format {fmt!r}; expected one of {sorted(EXPORT_FORMATS)}" + ) + + url = f"{self._base}/documents/export" + try: + with httpx.Client(timeout=self._timeout) as client: + response = client.post( + url, + headers={"Authorization": f"Bearer {self._key}"}, + json={"session_id": session_id, "format": fmt}, + ) + except httpx.RequestError as exc: + raise SuperDocsError(f"export unreachable: {type(exc).__name__}") from exc + + self.calls_made += 1 + if response.status_code >= 400: + raise SuperDocsError( + f"export -> HTTP {response.status_code}: " + f"{self._redact(response.text[:400])}" + ) + + data = response.content + if destination is not None: + destination.write_bytes(data) + logger.info("exported %s (%d bytes)", destination.name, len(data)) + return data + + # -- budget -------------------------------------------------------------- + + def ops_remaining(self) -> int | None: + """Promo operations left. + + Read from /v1/users/me/promotions because the documented alternative does not + work: the `usage` block on chat responses is null, and /v1/users/me/usage + rejects `sk_` keys with a 401. This is the only reliable spend meter. + """ + for promo in self._request("GET", "/users/me/promotions").get("active", []): + return promo.get("ops_remaining") + return None + + def whoami(self) -> dict[str, Any]: + return self._request("GET", "/agents/whoami") diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/requirements-dev.txt b/use-cases/Gyan0309/post-merger-integration-playbook/requirements-dev.txt new file mode 100644 index 00000000..4cf343bc --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt + +# The test suite needs no API key and no network — see tests/. +pytest==9.1.1 diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/requirements.txt b/use-cases/Gyan0309/post-merger-integration-playbook/requirements.txt new file mode 100644 index 00000000..d56d166c --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/requirements.txt @@ -0,0 +1,4 @@ +# Deliberately small. Detection and rendering need only PyYAML; httpx is for the +# SuperDocs half. Pins are exact so a clone builds what was tested. +httpx==0.28.1 +PyYAML==6.0.3 diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_cli_and_rendering.py b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_cli_and_rendering.py new file mode 100644 index 00000000..f6a1001f --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_cli_and_rendering.py @@ -0,0 +1,142 @@ +"""The paths a real user hits: typos, malformed files, no API key, and re-runs. + +None of these need a key or a network. They exist because the first thing a stranger +does with a tool is get the arguments slightly wrong, and what happens then decides +whether they try again. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from imo.cli import main # noqa: E402 +from imo.deal import load_deal # noqa: E402 +from imo.documents import CONFLICT_HEADING, conflict_instruction, plan_fan_out, render_all # noqa: E402 +from imo.conflicts import detect # noqa: E402 + +DEAL = ROOT / "deals" / "northstar.yaml" + + +class TestCliErrorPaths: + def test_a_missing_deal_file_gives_a_message_not_a_traceback(self, tmp_path, capsys) -> None: + """A mistyped filename is the single most common first interaction. A stack + trace tells the user the tool is broken; a sentence tells them they typoed.""" + code = main([str(tmp_path / "nope.yaml"), "--out", str(tmp_path / "out"), "--offline"]) + captured = capsys.readouterr() + + assert code != 0 + assert "Traceback" not in captured.err + assert "nope.yaml" in captured.err + + def test_malformed_yaml_gives_a_message_not_a_traceback(self, tmp_path, capsys) -> None: + bad = tmp_path / "bad.yaml" + bad.write_text("deal: [unclosed\n", encoding="utf-8") + + code = main([str(bad), "--out", str(tmp_path / "out"), "--offline"]) + captured = capsys.readouterr() + + assert code != 0 + assert "Traceback" not in captured.err + + def test_an_invalid_spec_names_the_problem(self, tmp_path, capsys) -> None: + bad = tmp_path / "bad.yaml" + bad.write_text("deal:\n code_name: X\ntarget_profile:\n headcount: 1\nworkstreams: []\n", encoding="utf-8") + + code = main([str(bad), "--out", str(tmp_path / "out"), "--offline"]) + assert code != 0 + assert "workstream" in capsys.readouterr().err + + def test_offline_needs_no_api_key(self, tmp_path, monkeypatch) -> None: + """The claim in the README. Verified by removing the key entirely.""" + monkeypatch.delenv("SUPERDOCS_API_KEY", raising=False) + assert main([str(DEAL), "--out", str(tmp_path / "out"), "--offline"]) == 0 + + def test_without_offline_and_without_a_key_it_says_so(self, tmp_path, monkeypatch, capsys) -> None: + monkeypatch.delenv("SUPERDOCS_API_KEY", raising=False) + code = main([str(DEAL), "--out", str(tmp_path / "out")]) + captured = capsys.readouterr() + + assert code != 0 + assert "SUPERDOCS_API_KEY" in captured.err + assert "--offline" in captured.err, "tell the user what they can still do" + + +class TestRendering: + def test_the_full_set_is_produced(self, tmp_path) -> None: + deal = load_deal(DEAL) + documents = render_all(deal) + + keys = {d.key for d in documents} + assert "charter" in keys + assert "day-one" in keys + assert "synergy-tracker" in keys + assert "comms-pack" in keys + assert sum(1 for k in keys if k.startswith("workstream-")) == len(deal.workstreams) + + def test_rendering_is_deterministic(self, tmp_path) -> None: + """Two runs over an unchanged deal must produce identical bytes, or every + regeneration shows a diff that means nothing.""" + deal = load_deal(DEAL) + first = {d.filename: d.body for d in render_all(deal)} + second = {d.filename: d.body for d in render_all(load_deal(DEAL))} + assert first == second + + def test_every_document_has_the_conflicts_section_it_will_need(self) -> None: + """The flagging instruction edits a section by name. If a document does not + have that heading, the edit silently has nowhere to go.""" + deal = load_deal(DEAL) + documents = {d.key: d for d in render_all(deal)} + plan = plan_fan_out(deal, detect(deal)) + + for key in plan: + assert CONFLICT_HEADING in documents[key].body, ( + f"{key} is due to receive conflict flags but has no " + f"'{CONFLICT_HEADING}' section for them to land in" + ) + + def test_the_day_one_checklist_is_specific_to_this_deal(self) -> None: + """The card asks for a checklist specific to this deal rather than a generic + list. The test of that is whether it names this deal's actual items and the + actual dependencies putting them at risk.""" + body = next(d for d in render_all(load_deal(DEAL)) if d.key == "day-one").body + + assert "DAY-HR-2" in body + assert "Items at Risk" in body + assert "DEP-PAY" in body, "the blocking dependency must be named, not just the item" + + def test_numbers_in_the_tracker_come_from_the_spec(self) -> None: + deal = load_deal(DEAL) + body = next(d for d in render_all(deal) if d.key == "synergy-tracker").body + + assert f"{deal.synergy_target_usd:,.0f}" in body + assert f"{deal.claimed_synergy_total:,.0f}" in body + + +class TestFlaggingInstruction: + def test_it_names_the_section_and_forbids_everything_else(self) -> None: + """A vague instruction gets a helpfully rewritten document, and 'helpfully + rewritten' is indistinguishable from 'silently damaged' three documents in.""" + deal = load_deal(DEAL) + conflict = detect(deal)[0] + instruction = conflict_instruction(conflict, audience=conflict.affects[0]) + + assert CONFLICT_HEADING in instruction + assert "Change nothing else" in instruction + assert conflict.summary in instruction + + def test_it_carries_this_document_s_own_stake(self) -> None: + """The same conflict reads differently in each charter — each lead needs to see + their own claim, not just the abstract clash.""" + deal = load_deal(DEAL) + duplicate = next(c for c in detect(deal) if c.kind == "duplicate_synergy") + + first, second = duplicate.affects[0], duplicate.affects[1] + assert conflict_instruction(duplicate, audience=first) != conflict_instruction( + duplicate, audience=second + ) diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_conflicts.py b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_conflicts.py new file mode 100644 index 00000000..c3d0ca32 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_conflicts.py @@ -0,0 +1,316 @@ +"""Conflict detection, tested exhaustively with no API key. + +Detection is pure arithmetic over the deal spec, so it earns thorough testing and gets +it in milliseconds. The valuable tests here are the ones asserting a comparator stays +**silent** — a detector that fires on everything is worthless in a way that is easy to +miss, because every "it found the thing" test still passes. +""" + +from __future__ import annotations + +import sys +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from imo.conflicts import ( # noqa: E402 + conflicts_for, + detect, + find_contested_dependencies, + find_duplicate_synergies, + find_synergy_over_commitment, + find_unmet_day_one, +) +from imo.deal import ( # noqa: E402 + DayOneItem, + Deal, + Dependency, + Synergy, + TargetProfile, + Workstream, +) + + +def _deal(workstreams, *, target=Decimal(10_000_000)) -> Deal: + return Deal( + code_name="Test", + acquirer="Acquirer Ltd", + target="Target Ltd", + announced=date(2026, 1, 1), + expected_close=date(2026, 3, 1), + integration_horizon_months=12, + deal_value_usd=Decimal(50_000_000), + synergy_target_usd=target, + rationale="", + profile=TargetProfile(headcount=100, locations=["X"], systems=[]), + workstreams=workstreams, + ) + + +def _ws(key, *, synergies=(), dependencies=(), day_one=()) -> Workstream: + return Workstream( + key=key, + name=key.title(), + lead="Lead", + scope="", + synergies=list(synergies), + dependencies=list(dependencies), + day_one=list(day_one), + ) + + +def _syn(id_, description, value, owner, month=6) -> Synergy: + return Synergy( + id=id_, + description=description, + annual_value_usd=Decimal(value), + realisation_month=month, + owner=owner, + ) + + +def _dep(id_, description, owner, month=3) -> Dependency: + return Dependency(id=id_, description=description, owner=owner, needed_by_month=month) + + +class TestDuplicateSynergy: + def test_identical_ids_across_workstreams_are_caught(self) -> None: + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Retire ERP licences", 500_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-1", "Retire ERP licences", 400_000, "tech")]), + ] + ) + found = find_duplicate_synergies(deal) + assert len(found) == 1 + assert set(found[0].affects) == {"finance", "tech"} + + def test_near_identical_descriptions_are_caught(self) -> None: + """The realistic failure: the same saving under two different ids.""" + deal = _deal( + [ + _ws("finance", synergies=[ + _syn("SYN-F2", "Terminate external analytics vendor contracts", 1_800_000, "finance") + ]), + _ws("tech", synergies=[ + _syn("SYN-T1", "Terminate the external analytics vendor contracts and platforms", 1_520_000, "tech") + ]), + ] + ) + assert len(find_duplicate_synergies(deal)) == 1 + + def test_genuinely_different_savings_are_not_flagged(self) -> None: + """The most important silence. Integration language is repetitive — 'consolidate + X systems' appears in every workstream — and a detector that flags all of them + trains people to ignore the report.""" + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Consolidate finance systems", 500_000, "finance")]), + _ws("people", synergies=[_syn("SYN-2", "Consolidate HR tooling", 300_000, "people")]), + ] + ) + assert find_duplicate_synergies(deal) == [] + + def test_one_workstream_with_two_similar_claims_is_its_own_business(self) -> None: + """Not a cross-workstream conflict. One owner can split a saving how they like.""" + deal = _deal( + [ + _ws("finance", synergies=[ + _syn("SYN-1", "Terminate vendor contracts phase one", 500_000, "finance"), + _syn("SYN-2", "Terminate vendor contracts phase two", 400_000, "finance"), + ]) + ] + ) + assert find_duplicate_synergies(deal) == [] + + def test_the_finding_quantifies_the_overstatement(self) -> None: + """A conflict a reader cannot act on is noise. The smaller claim is the amount + the total is inflated by.""" + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Retire ERP licences", 500_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-1", "Retire ERP licences", 400_000, "tech")]), + ] + ) + assert "$400,000" in find_duplicate_synergies(deal)[0].detail + + +class TestContestedDependency: + def test_two_owners_of_the_same_deliverable_are_caught(self) -> None: + deal = _deal( + [ + _ws("commercial", dependencies=[_dep("DEP-CRM", "Migrate CRM records to Salesforce", "commercial")]), + _ws("tech", dependencies=[_dep("DEP-CRM-2", "Migrate CRM records into Salesforce", "tech")]), + ] + ) + found = find_contested_dependencies(deal) + assert len(found) == 1 + assert set(found[0].affects) == {"commercial", "tech"} + + def test_unrelated_dependencies_are_not_flagged(self) -> None: + deal = _deal( + [ + _ws("commercial", dependencies=[_dep("DEP-1", "Novate customer contracts", "commercial")]), + _ws("tech", dependencies=[_dep("DEP-2", "Extend single sign-on to staff", "tech")]), + ] + ) + assert find_contested_dependencies(deal) == [] + + +class TestSynergyOverCommitment: + def test_claims_above_target_are_flagged(self) -> None: + deal = _deal( + [_ws("finance", synergies=[_syn("SYN-1", "Saving", 6_000_000, "finance")])], + target=Decimal(4_000_000), + ) + found = find_synergy_over_commitment(deal) + assert len(found) == 1 + assert "$2,000,000" in found[0].detail + + def test_claims_at_target_are_not_flagged(self) -> None: + """Boundary. Exactly on target is on target, not over it.""" + deal = _deal( + [_ws("finance", synergies=[_syn("SYN-1", "Saving", 4_000_000, "finance")])], + target=Decimal(4_000_000), + ) + assert find_synergy_over_commitment(deal) == [] + + def test_under_commitment_is_not_reported_here(self) -> None: + deal = _deal( + [_ws("finance", synergies=[_syn("SYN-1", "Saving", 1_000_000, "finance")])], + target=Decimal(4_000_000), + ) + assert find_synergy_over_commitment(deal) == [] + + def test_every_claiming_workstream_shares_the_finding(self) -> None: + """No single lead can resolve it, and showing it only centrally lets all of + them assume it is someone else's number that is wrong.""" + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "A", 3_000_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-2", "B", 3_000_000, "tech")]), + _ws("people"), # claims nothing + ], + target=Decimal(4_000_000), + ) + found = find_synergy_over_commitment(deal) + assert set(found[0].affects) == {"finance", "tech"} + assert "people" not in found[0].affects + + +class TestUnmetDayOne: + def test_an_item_depending_on_later_work_is_caught(self) -> None: + deal = _deal( + [ + _ws( + "people", + dependencies=[_dep("DEP-PAY", "Payroll cutover", "people", month=2)], + day_one=[DayOneItem(id="DAY-1", description="Staff paid", owner="people", depends_on=["DEP-PAY"])], + ) + ] + ) + found = find_unmet_day_one(deal) + assert len(found) == 1 + assert "month 2" in found[0].summary + + def test_an_item_depending_on_day_zero_work_is_fine(self) -> None: + """`needed_by_month: 0` means it is ready at close. That is not a conflict.""" + deal = _deal( + [ + _ws( + "people", + dependencies=[_dep("DEP-PAY", "Payroll cutover", "people", month=0)], + day_one=[DayOneItem(id="DAY-1", description="Staff paid", owner="people", depends_on=["DEP-PAY"])], + ) + ] + ) + assert find_unmet_day_one(deal) == [] + + def test_an_item_with_no_dependencies_is_fine(self) -> None: + deal = _deal( + [_ws("people", day_one=[DayOneItem(id="DAY-1", description="Letters sent", owner="people")])] + ) + assert find_unmet_day_one(deal) == [] + + def test_both_the_item_owner_and_the_dependency_owner_are_told(self) -> None: + """Neither can fix it alone: one must move the item, the other must pull the + dependency forward.""" + deal = _deal( + [ + _ws("tech", dependencies=[_dep("DEP-SSO", "Single sign-on", "tech", month=1)]), + _ws("people", day_one=[DayOneItem(id="DAY-1", description="Staff can log in", owner="people", depends_on=["DEP-SSO"])]), + ] + ) + found = find_unmet_day_one(deal) + assert set(found[0].affects) == {"people", "tech"} + + +class TestFanOut: + """The card's headline requirement, at the data level.""" + + @pytest.fixture + def deal(self) -> Deal: + return _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Retire ERP licences", 500_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-1", "Retire ERP licences", 400_000, "tech")]), + _ws("people"), + ] + ) + + def test_a_conflict_reaches_both_parties(self, deal) -> None: + conflicts = detect(deal) + assert conflicts_for(conflicts, "finance"), "finance must see it" + assert conflicts_for(conflicts, "tech"), "tech must see it" + + def test_an_uninvolved_workstream_is_not_told(self, deal) -> None: + """Fanning out to everyone is the same as not fanning out at all: it teaches + readers that most flags are not theirs.""" + assert conflicts_for(detect(deal), "people") == [] + + +class TestCleanDeal: + def test_a_consistent_deal_produces_no_conflicts(self) -> None: + """A reassuring nothing is a real answer. Manufacturing a finding to justify + the check having run is worse than finding none.""" + deal = _deal( + [ + _ws( + "finance", + synergies=[_syn("SYN-1", "Retire ERP licences", 500_000, "finance")], + dependencies=[_dep("DEP-COA", "Single chart of accounts", "finance")], + day_one=[DayOneItem(id="DAY-1", description="Balance sheet signed", owner="finance")], + ), + _ws("people", synergies=[_syn("SYN-2", "Remove duplicate HR tooling", 300_000, "people")]), + ], + target=Decimal(4_000_000), + ) + assert detect(deal) == [] + + +class TestDeterminism: + def test_detection_is_stable_across_runs(self) -> None: + """Ordering must not wobble, or every regeneration produces a spurious diff.""" + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Retire ERP licences", 500_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-1", "Retire ERP licences", 400_000, "tech")]), + ] + ) + assert [c.id for c in detect(deal)] == [c.id for c in detect(deal)] + + def test_high_severity_sorts_first(self) -> None: + deal = _deal( + [ + _ws("finance", synergies=[_syn("SYN-1", "Retire ERP licences", 3_000_000, "finance")]), + _ws("tech", synergies=[_syn("SYN-1", "Retire ERP licences", 3_000_000, "tech")]), + ], + target=Decimal(1_000_000), + ) + severities = [c.severity for c in detect(deal)] + assert severities == sorted(severities, key=lambda s: {"high": 0, "medium": 1}.get(s, 2)) diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_deal.py b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_deal.py new file mode 100644 index 00000000..9f0a099a --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/tests/test_deal.py @@ -0,0 +1,164 @@ +"""Deal loading, validation, and the edges a real deal file will hit. + +A malformed spec must fail loudly at load time. The failure mode of a half-read spec is +a complete, polished, wrong document set — and nobody reading a well-formatted +integration charter thinks to doubt the input file. +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from imo.conflicts import detect # noqa: E402 +from imo.deal import DealSpecError, load_deal # noqa: E402 + +MINIMAL = """ +deal: + code_name: Test + acquirer: A Ltd + target: B Ltd + announced: 2026-01-01 + expected_close: 2026-03-01 + deal_value_usd: 1000000 + synergy_target_usd: {target} +target_profile: + headcount: 10 +workstreams: + - key: finance + name: Finance +{extra} +""" + + +def _write(tmp_path: Path, *, target="1000000", extra="") -> Path: + path = tmp_path / "deal.yaml" + path.write_text(MINIMAL.format(target=target, extra=extra), encoding="utf-8") + return path + + +class TestTheShippedDeal: + def test_it_loads(self) -> None: + deal = load_deal(ROOT / "deals" / "northstar.yaml") + assert deal.code_name == "Northstar" + assert len(deal.workstreams) == 4 + + def test_every_comparator_fires_on_it(self) -> None: + """The sample deal exists to exercise the detectors. A comparator with nothing + to catch is a claimed capability nobody has seen work.""" + kinds = {c.kind for c in detect(load_deal(ROOT / "deals" / "northstar.yaml"))} + assert kinds == { + "duplicate_synergy", + "contested_dependency", + "synergy_over_commitment", + "unmet_day_one", + } + + +class TestValidation: + def test_a_missing_required_field_names_itself(self, tmp_path) -> None: + path = tmp_path / "deal.yaml" + path.write_text( + MINIMAL.format(target="1000000", extra="").replace(" acquirer: A Ltd\n", ""), + encoding="utf-8", + ) + with pytest.raises(DealSpecError, match="acquirer"): + load_deal(path) + + def test_a_deal_with_no_workstreams_is_refused(self, tmp_path) -> None: + path = tmp_path / "deal.yaml" + path.write_text( + MINIMAL.format(target="1000000", extra="").replace( + "workstreams:\n - key: finance\n name: Finance\n", "workstreams: []\n" + ), + encoding="utf-8", + ) + with pytest.raises(DealSpecError, match="at least one workstream"): + load_deal(path) + + def test_duplicate_workstream_keys_are_refused(self, tmp_path) -> None: + path = tmp_path / "deal.yaml" + path.write_text( + MINIMAL.format(target="1000000", extra="") + + " - key: finance\n name: Finance Again\n", + encoding="utf-8", + ) + with pytest.raises(DealSpecError, match="duplicate workstream key"): + load_deal(path) + + def test_a_close_before_the_announcement_is_refused(self, tmp_path) -> None: + path = tmp_path / "deal.yaml" + path.write_text( + MINIMAL.format(target="1000000", extra="").replace( + "expected_close: 2026-03-01", "expected_close: 2025-01-01" + ), + encoding="utf-8", + ) + with pytest.raises(DealSpecError, match="before"): + load_deal(path) + + def test_a_quoted_date_is_refused_rather_than_silently_wrong(self, tmp_path) -> None: + """YAML turns an unquoted date into a `date` and a quoted one into a string. + Accepting the string would put a value into date arithmetic that cannot do it.""" + path = tmp_path / "deal.yaml" + path.write_text( + MINIMAL.format(target="1000000", extra="").replace( + "announced: 2026-01-01", "announced: '2026-01-01'" + ), + encoding="utf-8", + ) + with pytest.raises(DealSpecError, match="expected a date"): + load_deal(path) + + def test_a_day_one_item_citing_an_unknown_dependency_is_refused(self, tmp_path) -> None: + """Referential integrity. Otherwise the checklist confidently lists an item + whose stated blocker does not exist.""" + extra = ( + " day_one:\n" + " - id: DAY-1\n" + " description: Something\n" + " depends_on: [DEP-NOPE]\n" + ) + with pytest.raises(DealSpecError, match="DEP-NOPE"): + load_deal(_write(tmp_path, extra=extra)) + + def test_a_dependency_needed_by_an_unknown_workstream_is_refused(self, tmp_path) -> None: + extra = ( + " dependencies:\n" + " - id: DEP-1\n" + " description: Something\n" + " needed_by: [marketing]\n" + ) + with pytest.raises(DealSpecError, match="marketing"): + load_deal(_write(tmp_path, extra=extra)) + + +class TestNumericEdges: + def test_money_with_symbols_and_separators_parses(self, tmp_path) -> None: + """A human editing a deal file will write $1,500,000. Refusing that is hostile.""" + deal = load_deal(_write(tmp_path, target="'$1,500,000'")) + assert deal.synergy_target_usd == Decimal(1_500_000) + + def test_a_zero_synergy_target_does_not_crash_detection(self, tmp_path) -> None: + """An early-stage deal legitimately has no target set yet. Percentage-of-target + arithmetic must not divide by it.""" + extra = ( + " synergies:\n" + " - id: SYN-1\n" + " description: A saving\n" + " annual_value_usd: 500000\n" + ) + deal = load_deal(_write(tmp_path, target="0", extra=extra)) + conflicts = detect(deal) # must not raise + assert any(c.kind == "synergy_over_commitment" for c in conflicts) + + def test_a_deal_with_no_synergies_at_all_is_clean(self, tmp_path) -> None: + deal = load_deal(_write(tmp_path, target="1000000")) + assert deal.claimed_synergy_total == Decimal(0) + assert detect(deal) == [] diff --git a/use-cases/Gyan0309/post-merger-integration-playbook/verify_roundtrip.py b/use-cases/Gyan0309/post-merger-integration-playbook/verify_roundtrip.py new file mode 100644 index 00000000..ccd8fa08 --- /dev/null +++ b/use-cases/Gyan0309/post-merger-integration-playbook/verify_roundtrip.py @@ -0,0 +1,113 @@ +"""Prove the edits were surgical, rather than asserting it. + +Compares every rendered document with its exported counterpart and reports what +actually changed. Run it after `python -m imo.cli deals/northstar.yaml --out build`: + + python verify_roundtrip.py build + +The claim under test is the brief's second behaviour — *"change what you meant to +change and nothing else, and be able to show that nothing else changed"* — so the +output is a count a reader can check, not a reassurance. + +Markdown formatting is normalised first, because a markdown → HTML → markdown round +trip legitimately rewrites presentation: `|---|` becomes `| --- |`, `*` bullets become +`-`, and trailing double-space line breaks are dropped. None of that is a content +change. Everything left over is. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Bullet markers a markdown round trip may legitimately produce. The model does not +# always choose the same one: across two runs of the identical pipeline the flags came +# back as "-" once and "•" the next. A verifier keyed to a single character reports +# a real, correctly-applied edit as an unexplained content change — which is the worst +# possible failure for a check whose entire job is telling you whether to trust the +# output. Found by running the pipeline twice, not by reading it. +BULLETS = "-*•·–—◦▪" + +FLAG = re.compile(rf"^[{re.escape(BULLETS)}]\s+\*\*\[?(HIGH|MEDIUM|LOW)\]?") +PLACEHOLDER = "_None recorded._" + + +def normalise(text: str) -> list[str]: + """Strip presentation, keep content.""" + lines: list[str] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + if re.fullmatch(r"\|[\s\-\|:]+\|", line): # table separator row + line = "|SEP|" + elif line.startswith("|"): # cell padding + line = "|" + "|".join(c.strip() for c in line.strip("|").split("|")) + "|" + line = re.sub(rf"^[{re.escape(BULLETS)}]\s+", "- ", line) # any bullet marker + lines.append(line) + return lines + + +def main(build: Path) -> int: + # Documents contain characters the Windows console encoding cannot represent, and + # the default behaviour is to mangle them into replacement characters mid-report — + # which makes a correct line look corrupt. Ask for UTF-8 where it is available. + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + exported_dir = build / "exported" + if not exported_dir.is_dir(): + print(f"no exported documents in {exported_dir}", file=sys.stderr) + return 2 + + print(f"{'document':<34} {'lines':>6} {'flags+':>7} {'other+':>7} {'other-':>7} verdict") + print("-" * 82) + + unexpected_total = 0 + flags_total = 0 + + for source in sorted(build.glob("*.md")): + exported = exported_dir / source.name + if not exported.exists(): + continue + + before = normalise(source.read_text(encoding="utf-8")) + after = normalise(exported.read_text(encoding="utf-8")) + before_set, after_set = set(before), set(after) + + added = [ln for ln in after if ln not in before_set] + removed = [ln for ln in before if ln not in after_set] + + flags = [ln for ln in added if FLAG.match(ln)] + other_added = [ln for ln in added if not FLAG.match(ln)] + # Losing the placeholder is the instructed change, not collateral damage. + other_removed = [ln for ln in removed if PLACEHOLDER not in ln] + + flags_total += len(flags) + unexpected_total += len(other_added) + len(other_removed) + + verdict = "surgical" if not (other_added or other_removed) else "!! CHANGED" + print( + f"{source.name:<34} {len(after):>6} {len(flags):>7} " + f"{len(other_added):>7} {len(other_removed):>7} {verdict}" + ) + + for line in other_removed: + print(f" LOST : {line[:110]}") + for line in other_added: + print(f" GAINED: {line[:110]}") + + print("-" * 82) + print(f"{flags_total} conflict flags added; {unexpected_total} unintended content changes") + + if unexpected_total: + print("\nFAIL: the edits were not surgical.") + return 1 + + print("\nPASS: every content change was an intended conflict flag.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else "build")))