feat: more skills

This commit is contained in:
m3tam3re committed 2026-10-09 03:28:20 +02:00
1 parent 348ef9f6b5
commit 02e2797fbd
26 files changed
+9435

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
---
name: chief-of-staff
description: Pursue a long-running goal in a single session by co-ordinating subagents.
disable-model-invocation: true
---
You are a chief of staff, co-ordinating subagents and schedules to pursue a long-running goal. This session will run for a long time, accruing tribal knowledge and helping you make long-term strategic decisions.
You are the Directly Responsible Individual for this goal. You are empowered to think much longer-term than you're used to. You must think on two tracks simultaneously:
- Tactical: how do I complete the immediate task?
- Strategic: how do I modify the environment to improve the outcomes of the _next_ task?
## Schedules
Harness-permitting, you will suggest recurring schedules which can help in achieving the goal.
## Subagents
All work should be done in subagents. Protect your context window.
Use background agents so you can stay in active dialogue with the user.
Communication to and from subagents should be sparse. Communicate primarily through **context pointers**: research notes, previous commits, and others. Don't duplicate information already available via pointers.
## Strategic View
As part of any and all work, FIRST consider how the environment the agents operate in might be improved. Agents thrive in the **pit of success**:
- API's and functions which are extremely constrained and limited
- Lint rules which force correctness
- CODING_STANDARDS.md files which let code reviewers enforce best practices
They also need relevant **data sources** to succeed:
- Logs from critical running processes, like dev servers (or production logs)
- Access to test environment databases
- Access to the browser (when necessary) for clicking around and taking screenshots
Finally, create environments (and codebases) that obey the **"no workarounds"** rule:
- No one-off workarounds, or hacks that bypass established processes
- Any deviations from conventions must be fixed proactively, before feature work is done
Be relentless in improving the environment. Use every user message as an excuse to search for these improvements.
@@ -0,0 +1,5 @@
interface:
display_name: "Chief Of Staff"
short_description: "Pursue a long-running goal in a single session by co-ordinating subagents"
policy:
allow_implicit_invocation: false
@@ -0,0 +1,507 @@
---
name: officecli-academic-paper
description: "Use this skill to build academic-style .docx output: journal / conference / thesis chapters carrying formal citation style (APA, Chicago, IEEE, MLA), numbered equations, figure & table cross-references, footnotes/endnotes, bibliography, or multi-column journal layout. Trigger on: 'research paper', 'journal paper', 'conference paper', 'manuscript', 'thesis', 'APA', 'MLA', 'Chicago', 'IEEE two-column', 'bibliography', 'hanging indent', 'citation style', 'abstract + keywords', 'equation numbering', 'cross-reference', paper with footnotes/endnotes. Output is a single .docx."
---
# OfficeCLI Academic Paper Skill
**This skill is a scene layer on top of `officecli-docx`.** Every docx hard rule — style architecture, heading hierarchy, shell quoting, page-break rules, live PAGE field, Delivery Gate, renderer quirks — is inherited, not re-taught. This file adds only what academic papers need on top: citation styles, equations, SEQ / PAGEREF cross-refs, multi-column journal layout, bibliography hanging indent, abstract/keywords/affiliation block.
When the docx base rules cover it, the text here says `→ see docx v2 §X`. Read docx v2 first if you have not.
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**This skill teaches what an academic paper requires, not every command flag.** When a prop name, enum value, or field instruction is uncertain, consult help BEFORE guessing.
```bash
officecli help docx # All docx elements
officecli help docx <element> # Full schema (e.g. section, equation, field, footnote)
officecli help docx <element> --json # Machine-readable
```
Help is pinned to the installed CLI version. **When this skill and help disagree, help wins.** Every `--prop X=` in this file has been grep-verified against `officecli help docx <element>` — if help adds / renames a prop in a later version, trust help.
## Mental Model & Inheritance
**Inherits docx v2.** You should have read `skills/officecli-docx/SKILL.md` first. This skill assumes you know how to add paragraphs, set styles, build tables, insert images, manage TOC/footer/headers, force page breaks, and run the Delivery Gate. If any of those are unfamiliar, open a second session on docx v2 before continuing.
## Shell & Execution Discipline
**Shell quoting, incremental execution, `$FILE` convention** → see docx v2 §Shell & Execution Discipline. The same rules apply here verbatim — quote `[N]` paths, single-quote any value containing `$` (including `$2.8B` in a body paragraph or `@` DOIs), never hand-write `\$ \t \n` in executable examples, one command at a time. Academic-paper examples below use `$FILE` as a shell variable (`FILE="thesis.docx"`).
## What "academic" means here (identity)
An academic paper is a docx with a **scholarly layer** on top: verifiable citations, precise equations, cross-refs that stay in sync, a formatted reference list. The base docx rules still apply; academic adds six deltas:
1. **Citation style is a contract.** APA / Chicago / IEEE / MLA each dictate author format, date placement, reference-list order, in-text marker shape. Pick one at the start; every later decision (hanging indent, footnote vs parenthetical, `[1]` vs `(Smith, 2024)`) follows.
2. **Equations are first-class content** — inline `oMath` inside prose, display `oMathPara` as standalone blocks, optionally numbered.
3. **Figures and tables auto-number.** `SEQ Figure` / `SEQ Table` fields count them; `PAGEREF` links "see Fig. 2" to its live page number.
4. **Bibliography uses hanging indent** (first line flush left, continuation lines indented). Not first-line indent. Not left indent alone. Hanging.
5. **Abstract / keywords / affiliation block** is a first-page three-piece, not a cover in the marketing sense. Block-style abstract, no first-line indent, no decoration.
6. **Multi-column layout** appears in IEEE / ACM / Nature / many journals: single-column abstract + two-column body.
### Reverse handoff — when to go BACK to docx
Stay in **docx v2** for white papers, policy briefs, technical reports, HR templates — anything without a venue / citation style. Use **this skill** only when the document will carry at least TWO of: citation-style biblio, equations, SEQ/PAGEREF cross-refs, multi-column, abstract + keywords block.
## Workflow — 5 verbs
1. **Read the venue spec.** APA 7 / Chicago 17 / IEEE / MLA 9 / journal-specific. Line spacing, font, citation shape, biblio sort order — everything downstream follows from this one decision.
2. **Plan the sections.** Abstract → keywords → introduction → methods → results → discussion → conclusion → references. Estimate heading count for TOC decision (3+ headings = add a TOC, see docx v2 §Table of Contents).
3. **Set styles up front.** Heading1 / Heading2 / Heading3 / Caption / AbstractTitle / Bibliography. Define all styles BEFORE any content (→ see docx v2 §Paragraphs and styles — same rule here, same failure mode if skipped).
4. **Build body in order.** Cover / title block → abstract → keywords → TOC (if needed) → body sections in reading order → figures / tables with SEQ captions → bibliography → footnotes are added last by paragraph path.
5. **QA — Delivery Gate.** Inherit docx v2 Gates 1-3, then add academic Gates 4-5 below.
## Requirements (academic floor on top of docx v2)
Everything in docx v2 §Requirements for Outputs applies. On top of that, academic papers MUST meet these additional rules:
### Typography and spacing (venue-aware)
- **Font.** Times New Roman 11-12pt body (default) or venue-specified (IEEE uses Times 10pt 2-col; APA allows Calibri 11pt). Same body font throughout; no decorative heading fonts.
- **Heading hierarchy.** H1 = 20pt bold, H2 = 14pt bold, H3 = 12pt bold italic, body = 11-12pt. (Same numbers as docx v2 — restated because academic papers never rely on Word defaults.)
- **Line spacing.** APA 7 = 2x (double). Chicago / IEEE / most journals = 1.5x. Never below 1.15x. Set on body paragraphs and on References.
- **Margins.** 1 inch (1440 twips) all sides unless the venue says otherwise (some journals require 1.25in left for binding — check the spec).
### Abstract, bibliography, caption placement
- **Abstract is block-style.** NO `firstLineIndent`. Use `spaceAfter=12pt` for paragraph separation. If `view issues` reports "body paragraph missing first-line indent" on an Abstract paragraph, it's a false positive — ignore.
- **Bibliography uses hanging indent.** Each entry is one paragraph with `indent=720 hangingIndent=720` (left indent 0.5", first-line reversed by same amount). First line flush left; wraps indent under author name.
- **Figure captions go BELOW the figure.** Table captions go ABOVE the table. This is the single rule most non-academics get wrong — APA, Chicago, IEEE, MLA all agree on it.
- **Citation round-trip.** Every in-text citation key must resolve to an entry in the reference list. Delivery Gate 4 verifies.
- **SEQ presence.** Any paper with numbered figures or tables must carry live `SEQ Figure` / `SEQ Table` fields (not hardcoded "Figure 1" text that drifts when you insert a new figure mid-document). Delivery Gate 5 verifies.
### Cover / first-page block
Academic covers differ from professional covers. Minimum elements: title (centered, 20-22pt bold), author(s), affiliation, submission target or journal, date, abstract, keywords. The "60% fill" rule from docx v2 §Visual delivery floor still applies — a three-line cover with half a page of whitespace is a fail. See §Abstract / keywords / affiliation block below for the first-page recipe.
### Section numbering convention (STYLE-DEPENDENT — do not apply blindly)
Academic section numbers are **part of the heading text**, not computed via list numbering. `officecli`'s `numId`/`listStyle` mechanism is fragile across Heading1 re-use, so hand-write the prefix. BUT the prefix shape varies by style — DO NOT use the same form for all four:
| Style | H1 format | H2 format | Example |
|---|---|---|---|
| **APA 7** | **UNNUMBERED centered bold** | Unnumbered left-aligned bold | `Introduction` / `Methods` (centered) |
| **Chicago** | `"N. Title"` left-aligned | `"N.M Title"` | `1. Introduction`, `2.1 Policy Formation` |
| **IEEE** | `"N. TITLE"` ALL CAPS + Roman numerals | `A. Subtitle` title case | `I. INTRODUCTION`, `II. RELATED WORK`, `A. Datasets` |
| **MLA 9** | Unnumbered left-aligned bold | Same | `Literature Review` (no prefix) |
APA 7 L1 headings are **centered, bold, unnumbered**; L2 are flush-left bold; L3 flush-left bold italic; L4/L5 run-in. Do NOT prefix APA headings with `1. / 2.` — that is Chicago/IEEE convention. IEEE wants ALL CAPS with Roman numerals (`I. INTRODUCTION`); inside each section, use `A./B./C.` sub-headings (title case). Arabic-numbered body sections are Chicago-style only.
**Exception for all four**: References / Bibliography / Works Cited / Acknowledgments are unnumbered regardless of style — omit the `N.` prefix.
## Quick Start — minimal APA paper
```bash
FILE="paper.docx"
officecli create "$FILE"
officecli open "$FILE"
officecli set "$FILE" / --prop defaultFont="Times New Roman"
officecli add "$FILE" /body --type paragraph --prop text="Remote Work and Team Cohesion" --prop align=center --prop size=20pt --prop bold=true --prop spaceAfter=24pt
officecli add "$FILE" /body --type paragraph --prop text="Alice Chen" --prop align=center --prop size=12pt
officecli add "$FILE" /body --type paragraph --prop text="Department of Psychology, Stanford University" --prop align=center --prop size=11pt --prop spaceAfter=24pt
officecli add "$FILE" /body --type paragraph --prop text="Abstract" --prop align=center --prop size=14pt --prop bold=true --prop spaceBefore=12pt --prop spaceAfter=6pt
officecli add "$FILE" /body --type paragraph --prop text="This study examines remote-work adoption on team cohesion across 18 months..." --prop size=12pt --prop lineSpacing=2x --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Keywords: remote work, team cohesion, psychological safety" --prop italic=true --prop size=11pt --prop spaceAfter=18pt
officecli add "$FILE" /body --type paragraph --prop text="1. Introduction" --prop style=Heading1 --prop size=20pt --prop bold=true --prop spaceBefore=18pt --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Remote-work research (Smith, 2024) has expanded since 2020..." --prop size=12pt --prop lineSpacing=2x --prop firstLineIndent=720
officecli add "$FILE" /body --type paragraph --prop text="References" --prop style=Heading1 --prop size=20pt --prop bold=true --prop spaceBefore=18pt --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Smith, J. (2024). Remote work and cohesion. Journal of Applied Psychology, 109(3), 412-430." --prop size=12pt --prop lineSpacing=2x --prop indent=720 --prop hangingIndent=720
officecli add "$FILE" / --type footer --prop type=default --prop align=center --prop size=10pt --prop field=page
officecli close "$FILE"
officecli validate "$FILE"
```
Ten-line skeleton. Real papers grow by adding more body paragraphs, more bibliography entries (each with the same `indent=720 hangingIndent=720` pair), figures / tables with captions, and a TOC if there are 3+ Heading1s. The Quick Start validates clean; the sections below elaborate each dimension.
## Citation style recipes
Four mainstream families. Pick one at project start; every downstream decision follows. **Per-style decision table:**
| Style | In-text shape | Reference list order | Body line spacing | Footnotes? |
|---|---|---|---|---|
| APA 7 | `(Smith, 2024)` or `Smith (2024)` | Alphabetical by author | 2x (double) | Rare (content notes only) |
| Chicago 17 (Notes-Bib) | Superscript footnote number | Alphabetical by author | 1.5x-2x | **Primary** (full citation in footnote) |
| IEEE | `[1]`, `[2]`, ..., `[N]` | Order of first citation | 1.15x-1.5x, 2-col | Rare |
| MLA 9 | `(Smith 412)` page-number | Alphabetical by author, "Works Cited" | 2x | Rare |
Shared defaults across all four: reference-list paragraphs use `indent=720 hangingIndent=720` (hanging indent 0.5"); add a live TOC if 3+ Heading1s (→ see docx v2 §Table of Contents); set `updateFields=true` and report TOC page numbers as uncomputed until a Word-compatible field engine updates them.
### APA 7 (social sciences — psychology, education, management)
- In-text: `(Author, Year)` or `Author (Year)` for narrative. Page number required on direct quotes: `(Smith, 2024, p. 15)`. Three+ authors: `(Smith et al., 2024)` after first citation.
- Reference list order: **alphabetical by first author's surname**. Title caps: sentence case for article titles, title case for journal names (italic).
- Reference shape: `Author, A. A., & Co-Author, B. B. (Year). Title of article. Journal Name, Volume(Issue), pages.` DOI preferred over URL; present as https URL, not `doi:` prefix.
- Double-space everything (`lineSpacing=2x`) including abstract and references. Body first-line indent = 0.5" (`firstLineIndent=720`).
```bash
# Body paragraph with parenthetical citation
officecli add "$FILE" /body --type paragraph --prop text="Remote work adoption accelerated during the pandemic (Kramer & Kramer, 2020)." --prop size=12pt --prop lineSpacing=2x --prop firstLineIndent=720
# Reference entry with hanging indent
officecli add "$FILE" /body --type paragraph --prop text="Kramer, A., & Kramer, K. Z. (2020). The potential impact of the Covid-19 pandemic on occupational status. Journal of Vocational Behavior, 119, 103442." --prop size=12pt --prop lineSpacing=2x --prop indent=720 --prop hangingIndent=720
# DOI hyperlink appended to the reference paragraph
officecli add "$FILE" "/body/p[last()]" --type hyperlink --prop url="https://doi.org/10.1016/j.jvb.2020.103442" --prop text="https://doi.org/10.1016/j.jvb.2020.103442"
```
QA: `officecli query "$FILE" 'paragraph[hangingIndent]'` returns every reference entry; zero references with first-line indent instead of hanging.
### Chicago 17 — Notes-Bibliography (humanities — history, philosophy, religion)
- In-text: superscript footnote number; full citation in the first footnote (`Timothy Brook, The Troubled Empire (Cambridge, MA: Harvard UP, 2010), 142.`); **shortened form** thereafter (`Brook, Troubled Empire, 150.`).
- **Repeat-citation rule (Chicago 17, op. cit. deprecated):**
- **Immediately-consecutive** citation of **the same source, same page** → `Ibid.`
- **Immediately-consecutive, different page** of same source → `Ibid., 22.`
- Non-consecutive repeat → **shortened form** (`Brook, Troubled Empire, 150.`), NOT `op. cit.`. Chicago 17 drops `op. cit.` — use shortened form every time except for immediate repeats.
- Bibliography at end, **alphabetical by first author's surname** ("Brook, Timothy."), hanging indent. Footnote body renders at the viewer's footnote default (typically 10pt); bibliography entries 12pt. (The `footnote` element exposes only `text` — size is not settable per-footnote; trust renderer defaults.)
- Typical split for primary-source-heavy papers: `Primary Sources` and `Secondary Sources` as two Heading2s under a single `Bibliography` Heading1. Book titles italic in both footnotes and bibliography.
- Chicago also has an Author-Date variant used in the sciences — if the venue specifies Chicago Author-Date, fall back to the APA recipe and change only the punctuation (no comma between author and year: `(Smith 2024)`).
```bash
# Body paragraph that will anchor a footnote, then the footnote itself
officecli add "$FILE" /body --type paragraph --prop text="The Ming dynasty's 海禁 policy shaped coastal trade for two centuries." --prop size=12pt --prop lineSpacing=1.5x --prop firstLineIndent=720
officecli add "$FILE" "/body/p[last()]" --type footnote --prop text="Timothy Brook, The Troubled Empire: China in the Yuan and Ming Dynasties (Cambridge, MA: Harvard University Press, 2010), 142."
# Next footnote — shortened form
officecli add "$FILE" "/body/p[last()]" --type footnote --prop text="Brook, Troubled Empire, 150."
# Bibliography section split — primary sources first
officecli add "$FILE" /body --type paragraph --prop text="Bibliography" --prop style=Heading1 --prop size=20pt --prop bold=true --prop spaceBefore=18pt
officecli add "$FILE" /body --type paragraph --prop text="Primary Sources" --prop style=Heading2 --prop size=14pt --prop bold=true --prop spaceBefore=12pt
officecli add "$FILE" /body --type paragraph --prop text="Ming Shilu 明實錄. Taipei: Academia Sinica, 1966." --prop size=12pt --prop indent=720 --prop hangingIndent=720
officecli add "$FILE" /body --type paragraph --prop text="Secondary Sources" --prop style=Heading2 --prop size=14pt --prop bold=true --prop spaceBefore=12pt
officecli add "$FILE" /body --type paragraph --prop text="Brook, Timothy. The Troubled Empire: China in the Yuan and Ming Dynasties. Cambridge, MA: Harvard University Press, 2010." --prop size=12pt --prop indent=720 --prop hangingIndent=720
```
QA: `officecli query "$FILE" 'footnote'` count ≥ body-paragraph citation count.
### IEEE (engineering — transactions, conference proceedings)
- In-text: `[1]`, `[2]`. Numbered in **order of first appearance**, not alphabetical. Reuse the same number for repeat citations. `[1, p. 15]` for page refs, `[1]-[3]` for a range.
- Reference entry starts with the bracketed number: `[1] A. Smith and B. Jones, "Title," IEEE Trans. X, vol. 5, no. 3, pp. 1-10, 2024, doi: ...`. Authors are initial-first; journal names abbreviated per IEEE list (`IEEE Trans. Neural Netw.`, not full name).
- Body is **two-column** (see §Multi-column below). Abstract is single-column above the fold, 10pt, 1.15x line spacing, typically 200-250 words.
- First-line indent on body paragraphs = 0.2" (`firstLineIndent=288` twips ≈ 14pt). Smaller than APA's 0.5" because the 2-col width is narrower.
- **Section headings: ALL CAPS with Roman numerals** — `I. INTRODUCTION`, `II. RELATED WORK`, `III. METHOD`. Sub-sections `A. Datasets`, `B. Baselines` in title case. Do NOT use `1. Introduction` (Arabic) for IEEE — that is Chicago style.
- **Tables are numbered Roman**: `Table I`, `Table II`, `Table III`. Figures remain Arabic (`Fig. 1`, `Fig. 2`). `recalcFields=seq` writes Arabic cached values for both — for IEEE Roman tables, either patch the cached `<w:t>` to Roman manually after recalc, or accept Arabic and note it in the cover letter.
```bash
# Body citing reference 1
officecli add "$FILE" /body --type paragraph --prop text="Attention-based anomaly detection has been applied to industrial sensor data [1], [2]." --prop size=10pt --prop lineSpacing=1.15x
# Reference list entry — number in the text
officecli add "$FILE" /body --type paragraph --prop text="[1] A. Smith and B. Jones, \"Attention for anomaly detection,\" IEEE Trans. Neural Netw., vol. 35, no. 2, pp. 412-430, 2024." --prop size=10pt --prop indent=720 --prop hangingIndent=720
officecli add "$FILE" /body --type paragraph --prop text="[2] C. Lee, \"Time-series anomaly survey,\" in Proc. ICML, 2023, pp. 1200-1215." --prop size=10pt --prop indent=720 --prop hangingIndent=720
```
QA: the highest `[N]` in body must equal the number of reference-list entries. Grep: `officecli view "$FILE" text | grep -oE '\[[0-9]+\]' | sort -u | tail -5`.
### MLA 9 (literature, languages, cultural studies)
Diff vs APA: in-text is `(Author Page)` **no comma** (e.g. `(Smith 412)`); direct quotes always carry the page number. Reference section titled **Works Cited** (not References / Bibliography). Entries alphabetical by surname, hanging indent, 2x spacing, nine "core elements" separated by periods: `Author. Title. Container, Other Contributors, Version, Number, Publisher, Date, Location.` — skip any that don't apply. Book titles italic; article titles in quotes. Otherwise identical to APA paragraph setup.
## Equations (OMML — inline vs display)
`--type equation` parses a LaTeX-ish formula into OMML. Two modes, selected by `--prop mode=`:
| Mode | XML | Visual | Use |
|---|---|---|---|
| `display` (default) | `<m:oMathPara>` at `/body` | Standalone centered block | Numbered equations, theorem statements |
| `inline` | `<m:oMath>` appended to a run inside a paragraph | Runs with the text | `if $x > 0$` style in prose |
```bash
# Display equation (own paragraph, centered) — explicitly set mode=display for clarity
officecli add "$FILE" /body --type equation --prop mode=display --prop formula="x^2 + y^2 = z^2"
# Display equation with Greek / subscript / integral — verify rendering below
officecli add "$FILE" /body --type equation --prop mode=display --prop formula="\\lambda_1 + \\alpha"
officecli add "$FILE" /body --type equation --prop mode=display --prop formula="\\frac{1}{2\\pi} \\int_0^{\\infty} e^{-x^2} dx"
# Inline equation INSIDE prose — required whenever variables like x_{t+1}, \lambda, etc. appear in a body paragraph:
officecli add "$FILE" /body --type paragraph --prop text="Given the weight " --prop size=11pt
officecli add "$FILE" "/body/p[last()]" --type equation --prop mode=inline --prop formula="W_t"
officecli add "$FILE" "/body/p[last()]" --type run --prop text=" we define the loss..."
```
**Verify equations render as OMML math**, not plain-text LaTeX tokens. After `close`, run:
```bash
officecli view "$FILE" text | head -20 # λ₁ + α, ∫₀∞, x² must appear as unicode math (verified renders)
officecli raw "$FILE" /document | grep -c '<m:oMathPara' # ≥ 1 per display equation
```
If the body prose contains raw `lambda_1`, `x_{t+1}`, `\alpha` or similar plain-text tokens (i.e., you typed them into a `paragraph --prop text=` instead of wrapping with `--type equation --prop mode=inline`), downstream viewers will render them as literal ASCII. **Rule: every mathematical variable / Greek letter / subscript in prose goes through `--type equation mode=inline`, never through `paragraph --prop text=`.**
**LaTeX subset pitfalls** (non-negotiable):
1. `\left(...\right)` / `\left[...\right]` with a sub/superscript **inside** the delimiters → parse error (`Error: cast object … Subscript`). An OUTER script (`\left(x+y\right)^2`) is fine; plain `(`, `)`, `[`, `]` always work and OMML auto-sizes them in display mode.
2. `move` on `/body/oMathPara[N]` reorders the display equation (it repositions the wrapping paragraph). `--before <path>` may leave a stray empty paragraph; prefer `--index` or `--after` for a clean reorder.
**Equation numbering** — no native `\eqno`. The journal-standard layout is **one line**: equation centered, number flush-right at the column edge (`Y = A(X) ⊗ X (1)`). Build it with two paragraph **tab stops** — a `center` tab at the column mid-point and a `right` tab at the column right edge — then lay out `[tab] equation(inline) [tab] (1)` in a single paragraph. Do NOT use a centered display equation followed by a separate right-aligned `(1)` line — that splits the number onto its own line and is the most common reason agents fail to reproduce the expected look.
```bash
# Tab positions depend on the COLUMN width (twips). Default blank doc = A4, 3.18cm margins
# → text width = 8300 twips.
# single column: center tab = 4150, right tab = 8300
# two columns (default 720-twip gutter): col = (8300-720)/2 = 3790 → center 1895, right 3790
# (other page size / margins: text width = pageWidth - marginLeft - marginRight; recompute.)
officecli add "$FILE" /body --type paragraph # the equation paragraph (say it lands at p[N])
officecli add "$FILE" "/body/p[N]" --type tab --prop pos=4150 --prop val=center
officecli add "$FILE" "/body/p[N]" --type tab --prop pos=8300 --prop val=right
officecli add "$FILE" "/body/p[N]" --type run --prop text=$'\t' # tab → jump to center
officecli add "$FILE" "/body/p[N]" --type equation --prop mode=inline --prop formula='Y = A(X) \otimes X'
officecli add "$FILE" "/body/p[N]" --type run --prop text=$'\t(1)' # tab → jump to right edge, then the number
```
For a two-column section just use the two-column tab positions (1895 / 3790); the same paragraph then centers the equation within its column with `(1)` at the column's right edge. Schema: `officecli help docx tab`.
**Do NOT place `--type equation` directly on a table cell `tc[N]` path** — it is rejected with a guard error (`table cells only accept paragraphs, tables, or SDTs`), so no bad XML is written. Target `tc[N]/p[1]` with `mode=inline` if you need equations in cells.
Full equation schema: `officecli help docx equation`.
## Figures, tables, and cross-references (SEQ + PAGEREF)
Two primitives, both **native fieldTypes** (`officecli help docx field` for the enum): `seq` for auto-numbered caption counters, `pageref` for "see Fig. 2 on page 7" back-references. Native fields insert with an unevaluated cached result; a single `set "$FILE" / --prop recalcFields=seq` (see §SEQ numbering below) fills the real numbers in document order — no per-field patching.
### SEQ auto-numbering — figures and tables
A SEQ field is a counter with a name (`identifier`). Every `SEQ Figure` increments the Figure counter on **recalc**; every `SEQ Table` increments the Table counter.
**SEQ cached values — one command, after all captions are added.** A freshly-added SEQ field has no evaluated cached number; `view text` shows the `#OCLI_NOTEVAL!{SEQ Figure}` sentinel (and `Format["evaluated"]=false`) until you recalc. **Do NOT patch each field by hand.** Once every figure/table caption is in place, run once:
```bash
officecli set "$FILE" / --prop recalcFields=seq
```
It counts `SEQ Figure` / `SEQ Table` fields in body document order and writes the real cached values (`Figure 1 / Figure 2 / Figure 3`), flipping `evaluated` true. Re-run it after inserting a caption mid-document so the numbers stay in sync. (Heading-relative `\s` and SEQ inside headers/footers defer to Word's own recompute — see `help docx document`.) Verify: `officecli view "$FILE" text` shows distinct ascending numbers.
```bash
# Figure with caption BELOW the image. Caption = "Figure <seq>: title" + optional bookmark for cross-ref.
officecli add "$FILE" /body --type picture --prop src=arch.png --prop width=5in
officecli set "$FILE" "/body/p[last()]/r[last()]" --prop alt="Model architecture: attention over time-series sensors"
# Caption paragraph (below the figure, per academic convention)
officecli add "$FILE" /body --type paragraph --prop text="Figure " --prop style=Caption --prop size=10pt --prop italic=true --prop align=center
officecli add "$FILE" "/body/p[last()]" --type field --prop fieldType=seq --prop identifier=Figure
officecli add "$FILE" "/body/p[last()]" --type run --prop text=": Attention-based anomaly detection model."
# Bookmark the caption so other paragraphs can PAGEREF it
officecli add "$FILE" /body --type bookmark --prop name=fig_arch
# (Numbers are filled later by a single `set / --prop recalcFields=seq`, after all captions exist.)
```
### PAGEREF — cross-reference by bookmark
```bash
# Cross-ref paragraph: "see Figure 1 on page X"
officecli add "$FILE" /body --type paragraph --prop text="As shown in Figure 1 (see page " --prop size=11pt --prop lineSpacing=1.5x
officecli add "$FILE" "/body/p[last()]" --type field --prop fieldType=pageref --prop name=fig_arch
officecli add "$FILE" "/body/p[last()]" --type run --prop text=")."
```
### Tables — caption ABOVE
```bash
# Caption first (ABOVE the table), THEN the table
officecli add "$FILE" /body --type paragraph --prop text="Table " --prop style=Caption --prop size=10pt --prop italic=true --prop spaceAfter=6pt
officecli add "$FILE" "/body/p[last()]" --type field --prop fieldType=seq --prop identifier=Table
officecli add "$FILE" "/body/p[last()]" --type run --prop text=": Participant demographics (N=47)."
officecli add "$FILE" /body --type table --prop rows=5 --prop cols=4 --prop width=100%
# ... fill header + rows per docx v2 §Tables
```
### Verify SEQ + PAGEREF fields landed
```bash
# At least one SEQ Figure or SEQ Table in the body document part
officecli raw "$FILE" /document | grep -c 'w:instrText[^>]*>[^<]*SEQ' # expect ≥ 1
officecli raw "$FILE" /document | grep -c 'w:instrText[^>]*>[^<]*PAGEREF' # 0 ok if no cross-refs
```
Live fields carry **cached values** that render stale until a human presses F9 in Word. Expect "Figure 1" to show as `1`, `2`, ... immediately after recalc; before recalc, some viewers render `0` or blank. Judge field presence by `fldChar` existence, not by visible digit (→ see docx v2 §Field / cached-value spot-check).
## Footnotes vs endnotes
**Footnote** — sits at the bottom of the page where its anchor paragraph lives. Used for source citations in Chicago Notes-Bib, content asides in any style.
**Endnote** — sits at the end of the document (or before the bibliography). Used by some venues in place of footnotes, or for long contextual notes that would clutter the page.
```bash
# Footnote anchored to paragraph N
officecli add "$FILE" "/body/p[3]" --type footnote --prop text="Smith et al. reported similar findings in their 2023 review."
# Endnote — anchored to paragraph N (like footnote); lands at /endnote[@endnoteId=N]
officecli add "$FILE" "/body/p[3]" --type endnote --prop text="Extended derivation of equation (4) is available at the project repository."
```
Both appear as empty-string runs in `view annotated` output (`r[N] ""`) — the run carries a `<w:footnoteReference>` XML element, not visible text. Confirm insertion with `officecli query "$FILE" 'footnote'` or `officecli get "$FILE" "/footnotes/footnote[N]"`. Footnotes do NOT shift paragraph indices; add them in any order after body content is in place. Full schema: `officecli help docx footnote` / `officecli help docx endnote`.
## Bibliography section
Every academic paper ends with a reference list. The name of the section depends on the style (**References** for APA / IEEE / Chicago Author-Date; **Bibliography** for Chicago Notes-Bib; **Works Cited** for MLA). Each entry is a separate paragraph with **hanging indent**.
```bash
# Section heading — same as body Heading1 (excluded from body numbering by convention)
officecli add "$FILE" /body --type paragraph --prop text="References" --prop style=Heading1 --prop size=20pt --prop bold=true --prop spaceBefore=18pt --prop spaceAfter=12pt
# Each entry: hanging indent 720 twips (0.5"), with indent=720 as the partner (first line flush, wraps indented)
officecli add "$FILE" /body --type paragraph --prop text="Smith, J. (2024). Remote work and cohesion. Journal of Applied Psychology, 109(3), 412-430." --prop size=12pt --prop lineSpacing=2x --prop indent=720 --prop hangingIndent=720
# DOI hyperlink on its own run appended to the entry paragraph
officecli add "$FILE" "/body/p[last()]" --type hyperlink --prop url="https://doi.org/10.1037/apl0001123" --prop text="https://doi.org/10.1037/apl0001123"
```
Verified: `--prop indent=720 --prop hangingIndent=720` is the canonical hanging-indent pair per `officecli help docx paragraph`. The old `ind.firstLine=-720` form (negative first-line indent) is NOT canonical and fails schema on emit — → see docx v2 §Schema-invalid-on-emit.
**Round-trip QA.** Count in-text citation markers (APA `(Author, Year)`, IEEE `[N]`, MLA `(Author N)`) vs reference-list entries. See Delivery Gate 4 below. Every cited key must resolve; every listed entry should be cited at least once.
## Multi-column (IEEE journal two-column recipe)
IEEE and many engineering / physics journals render body text in two columns with a single-column abstract above. The mechanism: a section break with `type=continuous` and `columns=2`, then another section break at the end to **revert** to single-column.
**The reversion step is not optional.** Without it, the rest of the document — including references — renders as two columns. This is the single most common multi-column failure.
```bash
FILE="ieee.docx"
officecli create "$FILE"
officecli open "$FILE"
# 1. Title, authors, affiliation — single-column (the default first section)
officecli add "$FILE" /body --type paragraph --prop text="Attention-Based Anomaly Detection for Industrial Time Series" --prop align=center --prop size=18pt --prop bold=true --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Alice Chen, Bob Martinez" --prop align=center --prop size=11pt
officecli add "$FILE" /body --type paragraph --prop text="Department of CS, Stanford University" --prop align=center --prop size=10pt --prop spaceAfter=18pt
# 2. Abstract — still single-column, block-style
officecli add "$FILE" /body --type paragraph --prop text="Abstract" --prop align=center --prop size=12pt --prop bold=true --prop spaceAfter=6pt
officecli add "$FILE" /body --type paragraph --prop text="We present an attention-based model for detecting anomalies in industrial sensor time series..." --prop size=10pt --prop lineSpacing=1.15x --prop spaceAfter=12pt
# 3. Section break + two-column from here on
# `/section[last()]` resolves to the final section (like p[last()]); an explicit /section[N] also works.
officecli add "$FILE" /body --type section --prop type=continuous
SECTION_COUNT=$(officecli query "$FILE" section --json | jq '.data.results | length')
# After the add, SECTION_COUNT should be 2 — [1] is pre-break, [2] is post-break (2-col body area).
officecli set "$FILE" "/section[2]" --prop columns=2 --prop columnSpace=1cm
# 4. Body — IEEE wants Roman numerals + ALL CAPS section titles (P1.2).
officecli add "$FILE" /body --type paragraph --prop text="I. INTRODUCTION" --prop style=Heading1 --prop size=10pt --prop bold=true
officecli add "$FILE" /body --type paragraph --prop text="Industrial anomaly detection has been studied since [1]..." --prop size=10pt --prop lineSpacing=1.15x --prop firstLineIndent=360
# 5. At the end of 2-column body, ANOTHER section break + revert to single column for references / appendices
# (If you want references in 2-col too, skip step 5 — but most IEEE papers use 2-col for references as well.)
# officecli add "$FILE" /body --type section --prop type=continuous
# Use /section[last()] for the final section, or re-count for an explicit /section[N]:
# officecli set "$FILE" "/section[3]" --prop columns=1
# 6. Footer, close, validate
officecli add "$FILE" / --type footer --prop type=default --prop align=center --prop size=9pt --prop field=page
officecli close "$FILE"
officecli validate "$FILE"
```
**Visual verify.** Run `officecli view "$FILE" html` and Read the returned HTML to audit the rendered output. The abstract must render as full-width and the introduction onward as two columns. If the abstract wraps into two narrow columns, the first section break landed before the abstract — move it.
**Section index bookkeeping.** Each `add /body --type section` inserts one empty paragraph into `/body` (the section-break marker). All subsequent `p[N]` indices shift by +1 per section break. Plan section breaks in advance; after adding a break, `officecli get "$FILE" /body --depth 1` to re-index before continuing.
Full section schema (`columns`, `columnSpace`, `orientation`, `pageNumFmt`, `titlePage`, `lineNumbers`): `officecli help docx section`.
## Abstract / keywords / affiliation block
First-page metadata stack: title (centered 20-22pt bold) → authors (centered 12pt, superscript `^1 ^2` for multi-affiliation) → affiliations (centered 11pt, keyed to superscripts) → submission target / date → **Abstract** heading (14pt bold) → abstract body (block-style, **NO `firstLineIndent`**, 150-300 words) → keywords line (italic 11pt). Same "cover ≥ 60% filled" rule as docx v2.
```bash
# Superscript affiliation markers (multi-institution paper)
officecli add "$FILE" /body --type paragraph --prop text="Alice Chen" --prop align=center --prop size=12pt
officecli add "$FILE" "/body/p[last()]" --type run --prop text="1" --prop superscript=true
officecli add "$FILE" "/body/p[last()]" --type run --prop text=", Bob Martinez"
officecli add "$FILE" "/body/p[last()]" --type run --prop text="2" --prop superscript=true
# Running header (skip on cover via type=first empty header — see docx v2 §headers)
officecli add "$FILE" / --type header --prop type=default --prop align=right --prop size=9pt --prop text="Short Running Title"
```
**Nature-family 2-col abstract** is rare — if required, open a `section type=continuous columns=2` BEFORE the abstract heading; short abstracts (<100 words) leave ragged columns. **Mirrored odd/even headers** are exposed by the high-level API: `officecli set "$FILE" /settings --prop evenAndOddHeaders=true`, then add the even header with `--type header --prop type=even`. Full header schema: `officecli help docx header`.
## QA — Delivery Gate (executable)
**Assume there are problems. Your job is to find them.** First render is almost never correct. Run this block before declaring done.
### Gates 1-3 — inherited from docx v2
→ see docx v2 §Delivery Gate. Schema validate, token leak grep, live PAGE field structure. Copy-paste the docx v2 gate block first. Every check must print its success message.
### Gate 4 — citation round-trip
Every in-text citation key should resolve to a bibliography entry. Count mismatches = REJECT.
```bash
# IEEE example (bracketed numerics). Adjust regex for APA (Author, Year) or MLA (Author Page).
CITATIONS=$(officecli view "$FILE" text | grep -oE '\[[0-9]+\]' | sort -u | wc -l)
ENTRIES=$(officecli query "$FILE" 'paragraph[hangingIndent]' --json | jq '.data.results | length')
echo "In-text citation markers: $CITATIONS | Bibliography entries: $ENTRIES"
# REJECT when citations exceed entries (cites without references). Entries > citations is allowed by some venues.
[ "$CITATIONS" -le "$ENTRIES" ] && echo "Gate 4 OK" || { echo "REJECT Gate 4: $CITATIONS in-text markers but only $ENTRIES bibliography entries"; exit 1; }
```
### Gate 5a — SEQ presence + cached numbers distinct
If the paper has any numbered figure or table, the body must carry live `SEQ` fields AND their cached values must show distinct ascending numbers (else `view text` and downstream viewers that don't recompute cached fields will show "Figure 1" for all).
```bash
# Count SEQ fields via query (raw-grep collapses multi-matches on one XML line → undercounts).
SEQ_COUNT=$(officecli query "$FILE" 'field[fieldType=seq]' --json | jq '.data.results | length')
VISIBLE_FIG=$(officecli view "$FILE" text | grep -cE '(Figure|Table) [0-9]+')
if [ "$VISIBLE_FIG" -gt 0 ] && [ "$SEQ_COUNT" -eq 0 ]; then
echo "REJECT Gate 5a: $VISIBLE_FIG visible Figure/Table labels but 0 SEQ fields."
exit 1
fi
# Cached values must be distinct. Run `set / --prop recalcFields=seq` once after all captions exist;
# before recalc the fields render the #OCLI_NOTEVAL! sentinel, after recalc Figure 1 / Figure 2 / Figure 3:
DISTINCT=$(officecli view "$FILE" text | grep -oE '(Figure|Table) [0-9]+' | sort -u | wc -l)
[ "$SEQ_COUNT" -le "$DISTINCT" ] && echo "Gate 5a OK (SEQ=$SEQ_COUNT, distinct=$DISTINCT)" || { echo "REJECT Gate 5a: $SEQ_COUNT SEQ fields but only $DISTINCT distinct rendered labels — run 'set \"$FILE\" / --prop recalcFields=seq'"; exit 1; }
```
### Gate 5b — Visual audit via HTML preview (MANDATORY, not optional)
Gates 1–5a catch schema, token leaks, live-field presence, citation counts. **They do NOT catch physical assembly defects** — scrambled page order, a duplicated Abstract mid-document, three figures all labeled "Fig. 1" despite SEQ field presence, equation variables rendering as plain-text LaTeX (`lambda_1`, `x_{t+1}`) instead of math. Do not skip — Gates 1–5a pass ≠ visual OK.
Run `officecli view "$FILE" html` and Read the returned HTML path. For every page of the paper, answer:
> (a) Are pages in logical academic sequence? (Title → Abstract → Keywords → Introduction → body → References — no forward jumps, no backward leaks.)
> (b) Does the Abstract appear exactly once, not duplicated mid-document?
> (c) Are Figure N / Table N labels distinct and ascending? (Fig. 1, Fig. 2, Fig. 3 — not all "Fig. 1". Same for tables.)
> (d) Do equations render as math? (Italicized variables, Greek letters like λ / α, proper integrals / fractions — NOT plain-text `lambda_1`, `x_{t+1}`, `\int`.)
> (e) For IEEE papers: are section titles ALL CAPS with Roman numerals (`I. INTRODUCTION`)? Are tables Roman (`Table I`, `Table II`)?
> (f) For APA papers: are Level-1 headings centered bold and unnumbered (not `1. Introduction`)?
> (g) Does every in-text "see Fig. N" / "see Table N" resolve to a figure/table that actually carries that number?
> (h) Heading hierarchy visually distinct (size + weight) across H1 / H2 / H3?
Report every instance. If even one defect is present → REJECT; do not deliver until fixed.
**Human preview (optional).** If you want the user to visually preview the paper, run `officecli watch "$FILE"` for a live preview the user can open at their own discretion, or have them open the `.docx` directly in Word / WPS / Pages. For final visual verification, open the file in the target viewer.
### Honest limit
`validate` catches schema errors, not academic-style errors. A document passes `validate` with APA citations in an IEEE paper, footnotes in a style that forbids them, or figures with hardcoded numbers that drift when a new figure is inserted. The gates above — especially Gate 4 (round-trip) and Gate 5 (SEQ presence) — are how you catch what validate cannot.
## Known Issues & Pitfalls (academic-specific)
→ Base pitfalls (shell escape, `\$ \t \n` literals, table cell formatting order, page-break boundaries, `shd.fill` / `ind.firstLine` schema-invalid forms, TOC cached values, watermark two-step): see docx v2 §Known Issues & Pitfalls.
Academic-specific:
- **`\left(...\right)` / `\left[...\right]` with a sub/superscript INSIDE the delimiters → parse error** (`cast object … Subscript`). An outer script (`\left(x+y\right)^2`) is fine. Use plain `(`, `)`, `[`, `]` — OMML auto-sizes in display mode.
- **`move` on `/body/oMathPara[N]` reorders the equation** (the wrapping paragraph moves). `--before <path>` may leave a stray empty paragraph; prefer `--index` / `--after`.
- **Section break +1 paragraph offset.** Each `add /body --type section` inserts one empty paragraph into `/body`. All `p[N]` indices after the break shift by +1. Plan breaks; after any `add section`, `officecli get "$FILE" /body --depth 1` to re-index.
- **`/section[last()]` resolves to the final section** (mirrors `p[last()]`), for both get and set. An explicit `/section[N]` also works:
```bash
SECTION_COUNT=$(officecli query "$FILE" section --json | jq '.data.results | length')
# use /section[last()] for the final section, or an explicit /section[2], /section[3], ...
```
Each `add /body --type section` increments the count. Re-query after every break.
- **Multi-column does NOT auto-revert.** After a `columns=2` section, you must add another section break and explicitly set `columns=1` on the new `/section[N]` (N = post-revert count) — otherwise the rest of the document, including references, renders as two columns. Verify with `officecli get "$FILE" "/section[N]"` for each N.
- **`--type equation` on a `tc[N]` cell path is rejected with a guard error** (`table cells only accept paragraphs, tables, or SDTs`) — no bad XML is written. Inside a table cell, target `tc[N]/p[1]` with `--prop mode=inline` instead.
- **SEQ caption numbers are filled by `set / --prop recalcFields=seq`** (run once after all captions exist), not by per-field raw-set patching. A pre-recalc SEQ field shows the `#OCLI_NOTEVAL!` sentinel in `view text`.
- **Hanging-indent canonical form is `indent=720 hangingIndent=720`.** Not `ind.firstLine=-720`. The dotted form emits `<w:ind>` after `<w:jc>` and fails schema on emit.
- **Footnote reference runs show as empty strings in `view annotated`.** The `<w:footnoteReference>` XML element has no visible text on the reference side; the note body lives in `/footnotes/footnote[N]`. Confirm with `officecli query "$FILE" 'footnote'`, not by eyeballing `view text`.
- **Caption placement:** Table caption ABOVE the table; Figure caption BELOW the figure. Every major style (APA, Chicago, IEEE, MLA) agrees. Putting a Table caption below the table is an academic-style error, not a rendering issue — `validate` will not catch it.
- **TOC cached rendering / shell-escape:** → see docx v2 §Table of Contents, §Shell escape.
## Renderer quirks (cross-viewer)
→ see docx v2 §Renderer quirks. PAGE / TOC cached values, OMML baseline shifts, scheme colors — all identical quirks apply to academic papers. Before calling an equation or a citation marker broken, open the file in the user's target viewer (Word, WPS, Pages) — if it renders correctly there, it is a viewer quirk, not a skill defect.
## Help pointer
When in doubt: `officecli help docx`, `officecli help docx <element>`, `officecli help docx <element> --json`. Help is the authoritative schema; this skill is the decision guide for academic deltas on top of docx v2.
@@ -0,0 +1,415 @@
---
name: officecli-data-dashboard
description: "Use this skill to build a multi-element Excel dashboard — Dashboard sheet on open, multiple formula-driven KPI cards, multiple charts, sparklines, and conditional formatting — from CSV or tabular input. Trigger on: 'dashboard', 'KPI dashboard', 'analytics dashboard', 'executive dashboard', 'metrics dashboard', 'CSV to dashboard', 'data visualization'. Output is a single .xlsx. Scene-layer on officecli-xlsx: inherits every xlsx hard rule. DO NOT invoke for: a single budget tracker / one-sheet CSV-with-formatting (use xlsx), a 3-statement / DCF / LBO financial model (use financial-model), a weekly report with at most 1 chart and fewer than 10 rows (use xlsx)."
---
# Data Dashboard (scene-layer on officecli-xlsx)
A dashboard is not "a spreadsheet with charts". It is a composition: **one Dashboard sheet the user lands on** with formula-driven KPI cards, cell-range-linked charts, sparklines, and semantic conditional formatting. Everything else (raw data, aggregations) is upstream infrastructure the user should never need to open. This skill teaches the composition pattern. Everything about the xlsx engine — cells, formulas, batch JSON, shell quoting, validate, HTML preview — comes from `officecli-xlsx` and is not re-taught here.
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**When a prop name, enum value, or alias is uncertain, consult help before guessing.**
```bash
officecli help xlsx # element list
officecli help xlsx chart # full schema for charts
officecli help xlsx sparkline # sparklines
officecli help xlsx conditionalformatting # all CF rule types
```
Help reflects the installed CLI version. When this skill and help disagree, **help wins**. DeferredAddKeys (`combosplit`, `holesize`) work on `add` only — see Reference.
## Mental Model & Inheritance
This skill **inherits every xlsx hard rule** from `officecli-xlsx` — shell quoting, zero formula errors, visual delivery floor, batch JSON shape (`{"command":"set"|"add","path":...,"props":{...}}` — key is `command`, NOT `action`), batch JSON dotted-name rule, chart data-feed forms, batch+resident limits, `validate` discipline. Read officecli-xlsx first; honour those rules, do not re-teach them here.
**Reverse handoff — do NOT use this skill when:**
- The ask is a **single-sheet CSV-with-formatting tracker** (no Dashboard sheet, no KPI cards, ≤ 1 chart) → go back to `officecli-xlsx`.
- The ask is a **3-statement / DCF / LBO financial model** with blue-inputs / black-formulas / cross-sheet drivers → use `officecli-financial-model`.
- The ask is a **weekly status report** with one SUMIF summary and one chart over < 10 rows → `officecli-xlsx`.
This skill only accepts: "a Dashboard sheet the user opens first, multiple KPI cards, multiple charts, some CF / sparklines".
## Shell & Execution Discipline
→ see officecli-xlsx §Shell & Execution Discipline for the baseline (quoting, heredoc for `!`, incremental execution).
Two increments specific to dashboards:
- **Long chart `add` commands exceed 180 chars.** Always split across lines with trailing `\`; never pack a chart command onto a single line. The longer the command, the higher the chance a shell-escape bug hides inside it.
- **Multi-instance counts use `query --json | jq length`, never `raw-get | grep -c`.** Example: `officecli query "$FILE" chart --json | jq '.data.results | length'` for "how many charts do I have?".
## Core Principles
Five non-negotiable principles. If any one is violated the output is not a dashboard, it is a spreadsheet that happens to have a chart.
1. **Formula-driven KPIs.** Every KPI value on the Dashboard sheet is a formula — `SUM`, `AVERAGE`, `IFERROR((...-...)/...,0)`, whatever — referring to cells on the Data / Summary sheet. Never hardcode a computed number. When the underlying data changes tomorrow, KPIs update on open.
2. **Cell-range references for charts.** Every chart series reads from a cell range: `series1.values="Sheet1!B2:B13"`. Inline `data="Revenue:100,200,300"` is for a 5-minute demo, not a delivered dashboard. The one exception: data requires an aggregation Excel cannot express (rare) — document the exception in a comment cell.
3. **Dashboard-first architecture.** KPI label cells, KPI value cells, charts, sparklines all live on the **Dashboard** sheet — the single sheet a user lands on. Raw imports and `SUMIFS` rollups live on Data / Summary sheets, upstream of the Dashboard. The user should never need to switch tabs to find the answer.
4. **Visible cells only for chart sources.** LibreOffice does not evaluate formulas in hidden columns or hidden sheets at render time. A chart whose `series1.values` points at a hidden-column `SUMIFS` renders blank. Pattern: aggregate into a **visible** Summary sheet, point charts at Summary cells, hide only helper columns that are not chart sources.
5. **Data-size-aware complexity.** A 10-row dataset does not get 5 KPIs and 4 charts. A 200-row dataset does not get 1 KPI and 1 chart. Scale up the composition with the input (table in §Design Ideas). Overbuilding is as wrong as underbuilding.
## Requirements
All `officecli-xlsx` requirements apply (→ see officecli-xlsx §Requirements for Outputs). Dashboards add these:
- **Dashboard sheet is the active tab on open.** Confirm 0-based sheet index with `officecli query "$FILE" sheet` BEFORE filling `activeTab="N"`. Never guess the index.
- **`calc.fullCalcOnLoad=true`.** Set via `officecli set "$FILE" / --prop calc.fullCalcOnLoad=true`. Do NOT `raw-set` `<calcPr>` — it produces duplicate elements that fail validate.
- **Refresh downstream cachedValue after every upstream edit.** `fullCalcOnLoad=true` schedules runtime recalc only; it does NOT refresh build-time `cachedValue`. After `set B=100 → set E==B+D → fix B=150`, E is stale until you re-issue E's formula (or close/reopen). Stale cache ships "Net Change = 0" to the board.
- **Every chart has a descriptive title and every series has a name.** `"Series1"` in a legend is unfinished work.
- **Every KPI value cell has a formula.** Verifiable: `officecli query "$FILE" 'Dashboard!:has(formula)' --json | jq '.data.results | length'` should equal your planned KPI count.
- **Header row fill on every data sheet.** Data sheet, Summary sheet, and any secondary data sheet need row 1 filled (e.g., `fill=1F3864 + font.color=FFFFFF + font.bold=true`).
- **10+ rows on Data sheet → ≥ 1 CF rule on a numeric column.** A 20-row table with zero visual scanning aid is a quality miss.
- **Dashboard value columns sized to the widest expected cachedValue — not a fixed 22.** Rule of thumb at 24pt bold + currency numFmt: `width ≈ ceil((visible_chars + 2) × 1.3)`. A KPI holding `¥1,958,414,250` (14 visible chars with currency + commas) needs `width ≥ 28`; a 4-digit KPI still needs `width ≥ 22` as the floor. Hardcoding `22` for a 10+ digit KPI is how `###` ships to the user.
- **Sparkline row height ≥ 20.** A sparkline in a default 15pt row is a flat squiggle — set `/Dashboard/row[N] height=22` (or 24 when paired with a 24pt KPI value cell in the same row).
- **Print deliverables set `_xlnm.Print_Area` scoped to Dashboard** + hide non-Dashboard sheets + add `<pageSetup fitToPage/>`. Without all three, the print pipeline emits every sheet and Dashboard lands on page 2+. See §Print-ready delivery for the exact commands.
## Quick Start
Minimal viable dashboard: 12-month revenue CSV → 4 KPIs + 1 line chart + activeTab + fullCalcOnLoad. Adapt the numbers, don't copy-paste blind. Broken into phases so a single failed phase is obvious.
**Phase 1 — Data sheet: create, import, format.**
```bash
FILE=my_dashboard.xlsx
officecli create "$FILE"
officecli import "$FILE" /Sheet1 --file sales.csv --header
officecli set "$FILE" '/Sheet1/col[A]' --prop width=12
officecli set "$FILE" '/Sheet1/col[B]' --prop width=15
officecli set "$FILE" '/Sheet1/B2:B13' --prop numFmt='$#,##0'
officecli set "$FILE" '/Sheet1/A1:B1' --prop fill=1F3864 --prop font.color=FFFFFF --prop font.bold=true
```
**Phase 2 — Dashboard sheet + one KPI card.**
```bash
officecli add "$FILE" / --type sheet --prop name=Dashboard
officecli set "$FILE" '/Dashboard/col[A]' --prop width=22
officecli set "$FILE" '/Dashboard/col[B]' --prop width=12
officecli set "$FILE" /Dashboard/A1 --prop value="Total Revenue" --prop font.size=9 --prop font.color=666666 --prop bold=true
officecli set "$FILE" /Dashboard/A2 --prop 'formula==SUM(Sheet1!B2:B13)' --prop numFmt='$#,##0' --prop font.size=24 --prop bold=true --prop font.color=2E7D32
```
**Phase 3 — Sparkline + chart.**
```bash
officecli add "$FILE" /Dashboard --type sparkline --prop cell=B2 --prop range='Sheet1!B2:B13' --prop type=line --prop color=4472C4 --prop highPoint=true --prop highMarkerColor=FF0000
officecli add "$FILE" /Dashboard --type chart \
--prop chartType=line \
--prop title="Revenue Trend" \
--prop series1.name="Revenue" \
--prop series1.values='Sheet1!B2:B13' \
--prop series1.categories='Sheet1!A2:A13' \
--prop preset=dashboard --prop axisNumFmt='$#,##0' \
--prop x=0 --prop y=5 --prop width=10 --prop height=15
```
**Phase 4 — fullCalcOnLoad → activeTab (LAST) → close → validate.**
```bash
officecli set "$FILE" / --prop calc.fullCalcOnLoad=true
# Resolve Dashboard's 0-based index from the actual sheet list — never hardcode.
DASH_IDX=$(officecli query "$FILE" sheet --json \
| jq '[.data.results[].path] | index("/Dashboard")')
officecli raw-set "$FILE" /workbook --xpath "//x:sheets" --action insertbefore \
--xml "<bookViews xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><workbookView activeTab=\"$DASH_IDX\" /></bookViews>"
officecli close "$FILE"
officecli validate "$FILE"
```
Verified end-to-end on a 12-row revenue CSV: `validate` reports no errors, Dashboard opens first, `Dashboard/A2.cachedValue` resolves (2,075,000 for the test data), chart renders with values linked.
## Design Ideas
Options, not templates. The user's data and audience drive the choices.
### Layout patterns (pick one, stay consistent)
**Pattern 1 — executive summary** (board packs): KPI strip A1:H4, charts stack from row 6.
```
┌ KPI1 │ KPI2 │ KPI3 │ KPI4 ┐ rows 1-4
├──────┴──────┴──────┴──────┤
│ Chart 1 (wide) │ rows 6-18
├───────────────┬───────────┤
│ Chart 2 │ Chart 3 │ rows 20-32
```
**Pattern 2 — ops console** (live ops): KPIs down A:B, charts fill C:L.
```
│ KPI1 │ │
│ KPI2 │ Chart 1 │ rows 1-12
│ KPI3 │ │
│ KPI4 ├───────────────────┤
│ KPI5 │ Chart 2 │ rows 14-26
```
**Pattern 3 — scorecard** (≥ 6 KPIs, no dominant chart): grid of 2×3 cards (label / value / sparkline).
```
│ KPI1 │ KPI2 │ KPI3 │ rows 1-4
│ KPI4 │ KPI5 │ KPI6 │ rows 5-8
```
### Complexity scaling by data size
| Rows | KPIs | Charts | Sparklines | CF rules | Preset |
|---|---|---|---|---|---|
| < 10 | 1–2 | 1 | skip | 0–1 | `minimal` |
| 10–50 | 2–3 | 2 | only if sequential time-series | 1–2 | `dashboard` |
| 50–200 | 3–5 | 2–3 | only if sequential time-series | 2–3 | `dashboard` |
| 200+ | 3–5 | 3 | only if sequential time-series | 3–4 | `dashboard` |
### Chart type selection
| Data pattern | Chart type | Notes |
|---|---|---|
| Trend over time, one series | `line` | Add `trendline=linear` to show direction on noisy series |
| Trend over time, multiple components | `line` (multi-series) or `columnStacked` | Stacked when components sum to a meaningful total |
| Comparison across categories in time order | `column` | Not `bar` — horizontal bars break left-to-right time reading |
| Part-of-whole breakdown | `doughnut` | Prefer over `pie`: `chartType=pie` has a known LibreOffice blank-render regression |
| Budget vs actual | `combo` with `combosplit=1` | First series as bars, rest as lines |
| Correlation | `scatter` | X-axis via `categories` / `series1.categories` — `series1.xValues` is UNSUPPORTED |
### Preset options
`--prop preset=<name>` on every chart. Options: `minimal`, `dashboard`, `corporate`, `magazine`, `colorful`, `monochrome`, `dark`. Pick one and stay consistent across all charts on a single Dashboard — mixing presets reads as accidental.
### Conditional formatting — semantic colors
Four CF rule types; each uses `--type <shorthand>` at `add` time:
| Intent | `--type` | Typical props |
|---|---|---|
| Magnitude bar (sales, spend) | `databar` | `sqref=B2:B13 color=4472C4` — explicit `min=0 max=<plausible>` recommended for predictable scaling, but omitting them is valid (defaults to data min/max) |
| Heat map (rates, growth) | `colorscale` | `sqref=D2:D13 mincolor=FFCDD2 midcolor=FFFFFF maxcolor=C8E6C9` |
| Status indicator | `iconset` | `sqref=E2:E13 iconset=3Arrows` — see help for the full enum |
| Custom business rule | `formulacf` | `sqref=B2:B13 'formula=$B2>=100000' fill=C8E6C9 font.color=2E7D32` — `font.bold` works on CF too |
Semantic colors to stay consistent within a dashboard:
- good / positive: fill `C8E6C9`, font `2E7D32`
- bad / negative: fill `FFCDD2`, font `C62828`
- neutral: fill `F5F5F5`, font `666666`
### KPI card anatomy
A card is a label cell + a value cell. The label is small gray (font.size=9, font.color=666666, bold); the value is large bold (font.size=24, bold=true, numFmt, font.color signals tone). One row of light fill (e.g. `F0F4FF`) across the card area gives the "card" read without building merged-cell scaffolds. Value column width must be sized to the largest cachedValue — never narrower than 22, often 26–32 for 8+ digit currency (see Requirements).
### Chart width budget by title length
At the `dashboard` preset's default title font, the chart plot-box width (in column units) must stay ahead of the title string, or the title clips mid-word. Rule of thumb: `chart.width ≥ ceil(title.length × 0.18)`. A 35-character title ("Department: Year-End Headcount vs Attrition Rate") needs `width ≥ 7`; be safer and use 10–12. If the anchor cannot be widened, shorten the title to ≤ 25 characters — clipped titles in a board-ready deliverable are indefensible.
`officecli get chart[N]` exposes numeric `width` (e.g. `width=480pt`) alongside `anchor` (e.g. `"A6:K21"`) at `.data.results[0].format`. Either is usable for Gate 2 — derive column span from anchor letters (A→K = 10 cols) when you need a column-unit budget.
### Print-ready delivery (board-pack / investor-send / one-pager)
Triggers: ask contains "print" / "一页" / "董事会" / "投资人". Four artefacts on the Dashboard sheet; non-Dashboard sheets hidden so the print pipeline emits one page only.
```bash
# 1. Print_Area scoped to Dashboard (xlnm convention).
officecli add "$FILE" / --type namedrange --prop name=_xlnm.Print_Area --prop scope=Dashboard --prop 'refersTo=Dashboard!$A$1:$H$36'
# 2. fit-to-page on Dashboard.
officecli raw-set "$FILE" /Dashboard --xpath "//x:worksheet" --action prepend --xml '<sheetPr xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><pageSetUpPr fitToPage="1"/></sheetPr>'
# 3. Landscape page setup.
officecli raw-set "$FILE" /Dashboard --xpath "//x:sheetData" --action insertafter --xml '<pageSetup orientation="landscape" paperSize="9" fitToWidth="1" fitToHeight="1"/>'
# 4. Hide non-Dashboard sheets — Print_Area scope alone does NOT stop the print pipeline from emitting every visible sheet.
for S in Sheet1 Summary; do
officecli raw-set "$FILE" /workbook --xpath "//x:sheet[@name='$S']" --action setattr --xml "state=hidden" || true
done
```
Delete any `Print_Area` set on Data / Summary sheets — conflicting scopes emit multi-page output.
## QA (REQUIRED — Delivery Gate)
**Assume there are problems. Your job is to find them.** A chart that was rendered does not mean a chart that was meaningful. "validate pass" is not delivery; "the Dashboard sheet reads like someone who knows the business made it" is delivery.
### Minimum cycle before "done"
Inherit the xlsx baseline (`view issues`, formula error queries, `validate`, HTML preview scan): → see officecli-xlsx §QA minimum cycle.
Then run the dashboard-specific Delivery Gates. Each gate uses **COUNT-then-if** pattern with a `.data.*` wrapper — never chain `&& echo OK || echo FAIL`.
**Gate 1 — KPI formula coverage.** Every planned KPI cell must carry a formula. Adjust `-lt 2` to your plan (4 KPIs → `-lt 4`).
```bash
KPI_FORMULAS=$(officecli query "$FILE" 'Dashboard!:has(formula)' --json | jq '.data.results | length')
[ "$KPI_FORMULAS" -lt 2 ] && { echo "REJECT Gate 1: $KPI_FORMULAS formula cells on Dashboard"; exit 1; }
```
**Gate 2 — Chart count matches plan, every chart has data + plausible title width.**
```bash
CHART_COUNT=$(officecli query "$FILE" chart --json | jq '.data.results | length')
[ "$CHART_COUNT" -lt 1 ] && { echo "REJECT Gate 2: zero charts"; exit 1; }
col_num () { local c=$1 n=0; for ((k=0;k<${#c};k++)); do n=$((n*26+$(printf '%d' "'${c:$k:1}")-64)); done; echo "$n"; }
for i in $(seq 1 "$CHART_COUNT"); do
JSON=$(officecli get "$FILE" "/Dashboard/chart[$i]" --json)
SC=$(echo "$JSON" | jq -r '.data.results[0].format.seriesCount // 0')
TITLE=$(echo "$JSON" | jq -r '.data.results[0].format.title // ""')
ANCHOR=$(echo "$JSON" | jq -r '.data.results[0].format.anchor // ""')
[ "$SC" = "0" ] || [ -z "$TITLE" ] && { echo "REJECT Gate 2: chart[$i] seriesCount=$SC title='$TITLE'"; exit 1; }
[ -z "$ANCHOR" ] && continue
LCOL=$(echo "${ANCHOR%%:*}" | sed 's/[0-9]*$//'); RCOL=$(echo "${ANCHOR##*:}" | sed 's/[0-9]*$//')
SPAN=$(( $(col_num "$RCOL") - $(col_num "$LCOL") + 1 ))
MIN=$(( (${#TITLE} * 18 + 99) / 100 ))
[ "$SPAN" -lt "$MIN" ] && { echo "REJECT Gate 2: chart[$i] title=${#TITLE} chars needs width ≥ $MIN, anchor spans $SPAN"; exit 1; }
done
```
Narrower titles at preset `minimal` / `magazine` may clip earlier than the 0.18 factor — spot-check.
**Gate 3 — Chart series names populated (no "Series1" in legend).**
```bash
for i in $(seq 1 "$CHART_COUNT"); do
BAD=$(officecli get "$FILE" "/Dashboard/chart[$i]" --json | jq '[.data.results[0].children[]? | select(.type == "series") | select((.format.name // "") | test("^Series[0-9]+$"; "i"))] | length')
[ "$BAD" -gt 0 ] && { echo "REJECT Gate 3: chart[$i] has $BAD auto-named series"; exit 1; }
done
```
**Gate 4 — CF rules on Data sheet (10+ rows).**
```bash
CF_COUNT=$(officecli query "$FILE" conditionalformatting --json | jq '.data.results | length')
[ "$CF_COUNT" -lt 1 ] && { echo "REJECT Gate 4: zero CF rules on 10+ row data sheet"; exit 1; }
```
Note: `query conditionalformatting` is the canonical element name; `query cf` returns 0 (not an alias).
**Gate 5 — activeTab and fullCalcOnLoad set.** Compare against real Dashboard index (Dashboard-at-index-0 is a true pass).
```bash
DASH_IDX=$(officecli query "$FILE" sheet --json | jq '[.data.results[].path] | index("/Dashboard")')
ACTIVE=$(officecli get "$FILE" /workbook --json | jq '.data.results[0].format.activeTab // -1')
FULLCALC=$(officecli get "$FILE" /workbook --json | jq -r '.data.results[0].format["calc.fullCalcOnLoad"] // false')
[ "$ACTIVE" != "$DASH_IDX" ] && { echo "REJECT Gate 5: activeTab=$ACTIVE Dashboard=$DASH_IDX"; exit 1; }
[ "$FULLCALC" != "true" ] && { echo "REJECT Gate 5: calc.fullCalcOnLoad=$FULLCALC — stale caches will ship"; exit 1; }
```
**Gate 6 — Placeholder sweep.** No build-time tokens in rendered output.
```bash
LEAKS=$(officecli view "$FILE" text 2>/dev/null | grep -niE '\{\{|\$fy\$|<TODO>|xxxx|TBD' | wc -l | tr -d ' ')
[ "$LEAKS" -gt 0 ] && { echo "REJECT Gate 6: $LEAKS placeholder tokens"; exit 1; }
```
**Gate 7 — Visual delivery floor (ported from xlsx).** Run `officecli view "$FILE" html` and Read the returned HTML path. Confirm:
- No `###` in any Dashboard or Data cell (columns too narrow).
- No truncated KPI labels, sheet tab names, or chart titles.
- No placeholder tokens rendered as text (`$fy$24`, `{var}`, `<TODO>`, `xxxx`).
- Pie / doughnut slices render with distinct fill colors (if collapsed in LibreOffice, verify in the user's target viewer before declaring broken — → see officecli-xlsx §Known Issues/Renderer caveats).
- No empty chart anchors — every chart has a visible, plausible plot.
- Dashboard sheet opens first (tab highlighted, active area scrolled to top).
If `view html` is blocked (renderer conflict, headless, port busy), Gate 7 is still **mandatory** — run ALL fallback checks:
```bash
# a) Token / ### sweep.
officecli view "$FILE" text 2>/dev/null | grep -nE '###|\{\{|<TODO>|\$fy\$|xxxx' && { echo "REJECT Gate 7: tokens or ### present"; exit 1; }
# b) Per-KPI: cachedValue length × coef must fit col width. coef=0.55 fit-to-page, 0.85 otherwise.
for CELL in A2 C2 E2 G2; do
CV=$(officecli get "$FILE" "/Dashboard/$CELL" --json | jq -r '.data.results[0].format.cachedValue // .data.results[0].text // ""')
W=$(officecli get "$FILE" "/Dashboard/col[${CELL%%[0-9]*}]" --json | jq -r '.data.results[0].format.width // 0')
CAP=$(echo "$W * 0.55" | bc -l | awk '{print int($1)}')
[ "${#CV}" -gt "$CAP" ] && { echo "REJECT Gate 7: $CELL '$CV' (${#CV} chars) > cap $CAP"; exit 1; }
done
# c) Rerun Gate 2 title × 0.18 ≤ anchor span. d) Log which fallback was used and why.
```
Gate 7 must **NEVER** be skipped — skipping ships `###` to the user.
If scene keywords include print / 一页 / board / 投资人 / 董事会, extend Gate 7 with a structural print-scope check:
```bash
if echo "$USER_REQ" | grep -qiE 'print|一页|投资人|董事会|board'; then
# Every non-Dashboard sheet must be hidden or veryHidden.
# query sheet --json carries the name in .preview (no .name/.state); visibility lives in
# each sheet's own get .format.hidden (absent => visible), so iterate + probe per sheet.
LEAKING=""
while IFS=$'\t' read -r SPATH SNAME; do
[ "$SNAME" = "Dashboard" ] && continue
HIDDEN=$(officecli get "$FILE" "$SPATH" --json | jq -r '.data.results[0].format.hidden // false')
[ "$HIDDEN" != "true" ] && LEAKING="$LEAKING $SNAME"
done < <(officecli query "$FILE" 'sheet' --json | jq -r '.data.results[] | [.path, .preview] | @tsv')
[ -n "$LEAKING" ] && { echo "REJECT Gate 7 print-scope: visible non-Dashboard sheet(s):$LEAKING — hide before delivery"; exit 1; }
# Dashboard must carry an explicit Print_Area named range.
PA=$(officecli query "$FILE" 'namedrange[name="_xlnm.Print_Area"]' --json | jq '.data.results | length')
[ "$PA" -ge 1 ] || { echo "REJECT Gate 7 print-scope: no _xlnm.Print_Area set"; exit 1; }
fi
```
The user opens the file in their target viewer (Office / WPS / Numbers) for the final print preview — the skill does not render export artefacts.
**Gate 8 — Formula sanity (cachedValue real, not stale/error).** `fullCalcOnLoad=true` refreshes at runtime, NOT build-time cache — so every formula cell must carry a non-empty, non-zero, non-error `cachedValue` now.
```bash
for CELL in A2 C2 E2 G2; do
JSON=$(officecli get "$FILE" "/Dashboard/$CELL" --json)
[ -z "$(echo "$JSON" | jq -r '.data.results[0].format.formula // ""')" ] && continue
CV=$(echo "$JSON" | jq -r '.data.results[0].format.cachedValue // ""')
case "$CV" in
"" | "0" | "#DIV/0!" | "#REF!" | "#N/A" | "#VALUE!" | "#NAME?" | "null")
echo "REJECT Gate 8: $CELL cachedValue='$CV' — re-issue formula or close+reopen"; exit 1 ;;
esac
done
```
If a KPI is genuinely zero (e.g. "terminations this quarter" = 0), whitelist it in the loop and document — default assumption is "zero is broken".
If anything fails, fix at source, re-run the full cycle.
### Honest limits
Scatter charts do not accept `series1.xValues` (UNSUPPORTED) — feed the x-axis via `categories` / `series1.categories`. LibreOffice chart color drift / pie-slice collapse / checkbox double-box are viewer artifacts — spot-check in Office / WPS / Numbers first.
## Reference
- **Shorthand `--type` at `add`:** `chart`, `sparkline`, `databar`, `colorscale`, `iconset`, `formulacf`. CF rules map to `help xlsx conditionalformatting`; path suffix `/Sheet/cf[N]`.
- **Full schemas live in help:** `officecli help xlsx chart` / `sparkline` / `conditionalformatting`. This skill does not mirror them.
- **DeferredAddKeys (add-only):** `combosplit`, `holesize`. See D-1. (`preset`, `trendline`, `referenceline`, `axisNumFmt` now work on `set` too — help shows `[add/set]`.)
- **Build order:** charts + sparklines + CF + tabColors first → `calc.fullCalcOnLoad=true` via high-level `set` → `raw-set activeTab` **LAST** (after all sheets exist).
## Known Issues & Pitfalls
### Dashboard-specific
| # | Issue | Mitigation |
|---|---|---|
| D-1 | `combosplit` is a DeferredAddKey — works on `add` only. (`preset`, `referenceline`, `trendline`, `axisNumFmt` now apply on `set` too — help shows `[add/set]`.) | Set `combosplit` at `add` time; cannot apply after the fact — remove + re-add. The other four can be applied or changed post-creation via `set`. |
| D-2 | `referenceline` format is `value:color:label:dash` (color BEFORE label). `"0:Break-Even:FF0000:dash"` fails `Invalid color value`. | Order is value, color, label, dash. |
| D-3 | Scatter charts do NOT accept `series1.xValues` (UNSUPPORTED). Feed the x-axis through `categories` / `series1.categories`. | `--prop series1.categories="Sheet1!A2:A13"` (or `--prop categories="Sheet1!A2:A13"`) |
| D-4 | `formulacf` honors `font.bold` / `font.italic` (written to the dxf font and surfaced on readback), alongside `fill` and `font.color`. | Use any of `fill` / `font.color` / `font.bold` / `font.italic` to signal a CF rule. |
| D-5 | Dashboard column widths default to 8.43 — KPI values at 24pt bold show `###` | Size by cachedValue bracket: 4–6 digits → 22–24; 7–9 digits (million) → 26–30; 10+ digits (亿 / billion) → 32–36; 百亿 / 10-digit + currency symbol + fit-to-page landscape → **40–44**. Formula `ceil((visible_chars+2)*1.3)` is a starting point; always verify via Gate 7 fallback b). Sparkline columns: 12. |
| D-6 | `raw-set activeTab` must be the LAST mutation. Inserting before all sheets exist shifts indices. | Finish all sheets / charts / CF / sparklines / tabColors, then `raw-set`. |
| D-7 | `calc.fullCalcOnLoad` via `raw-set` creates duplicate `<calcPr>` → validate fails | Use `officecli set "$FILE" / --prop calc.fullCalcOnLoad=true`. |
| D-8 | LibreOffice does not evaluate hidden-column formulas at render → charts referencing hidden cells render blank | Aggregate into a visible Summary sheet, chart reads from Summary. Hide only columns that are not chart sources. |
| D-9 | `chartType=pie` blank-renders in LibreOffice | Use `doughnut` as the safe substitute for part-of-whole breakdowns. |
| D-10 | `SUMIFS` / `AVERAGEIFS` with date criteria fails silently if the criterion is a string | Wrap with `DATE()` or `DATEVALUE()`: `=SUMIFS(B2:B13,A2:A13,DATE(2025,1,5))`. |
| D-11 | Summary sheet percentage formulas display as raw decimals (0.098) without `numFmt` | Set `numFmt="0.0%"` at the same `set` call as the formula. |
| D-12 | `import --header` sets freeze + AutoFilter but does NOT set column widths. | Set widths on `col[]`. `numFmt` on a `col[]` path now applies a column-level style (`<col s=...>`, schema-valid, reads back as `numberformat`); it formats blank cells in the column. Cells with their own style still need a per-cell-range `numFmt`. |
| D-13 | Sparkline `highpoint` is a bool (highlight on/off), not a color. `--prop highpoint=FF0000` errors `Invalid boolean value` | `--prop highPoint=true --prop highMarkerColor=FF0000`. Same pattern for lowPoint / firstPoint / lastPoint and their *MarkerColor. |
| D-14 | Sparkline cross-sectional data is meaningless (a region or department has no ordering) | Skip sparklines unless rows are a sequential time-series (dates, months, quarters). |
| D-15 | Empty chart `add` is rejected (`Chart requires a 'data' property`) at the CLI layer — legacy skills that relied on silent accept will fail here | Always provide `series1.values=` / `dataRange=` / inline `data=` at chart `add` time. Treat Gate 2 seriesCount check as a belt-and-braces verification. |
| D-16 | `fullCalcOnLoad=true` guarantees a **runtime** recalc when the end user opens the file; it does NOT refresh the build-time `cachedValue` in XML. Build sequence `set B=100 → set E==B+D → fix B=150` leaves `E.cachedValue` stale (board sees "Net Change = 0"). | After all upstream edits are final, re-issue every downstream formula (`officecli set "$FILE" /Sheet/E2 --prop formula==B2+D2`) OR `close` + re-open the file. Gate 8 verifies. |
| D-17 | The built-in calc engine does NOT evaluate `SUMPRODUCT` with array-predicate form `SUMPRODUCT((A2:A97=X)*C2:C97*D2:D97)` — cachedValue stays `0`/`null`, Gate 8 rejects. Runtime Excel / WPS compute fine, but board-delivered XLSX with stale cache still ships `0`. | Rewrite as helper column + `SUMIF`: `F2==C2*D2` on source sheet, then `=SUMIF(B:B, "Region X", F:F)`. Or pre-aggregate in Summary sheet and chart from there. |
### Inherited (pointer only)
Cross-sheet `!` trap, chart `anchor` / series immutability after create → see officecli-xlsx §Known Issues.
+562
View File
@@ -0,0 +1,562 @@
---
name: officecli-docx
description: "Use this skill any time a .docx file is involved -- as input, output, or both. This includes: creating Word documents, reports, letters, memos, or proposals; reading, parsing, or extracting text from any .docx file; editing, modifying, or updating existing documents; working with templates, tracked changes, comments, headers/footers, or tables of contents. Trigger whenever the user mentions 'Word doc', 'document', 'report', 'letter', 'memo', or references a .docx filename."
---
# OfficeCLI DOCX Skill
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**This skill teaches what good docx looks like, not every command flag. When a property name, enum value, or alias is uncertain, consult help BEFORE guessing.**
```bash
officecli help docx # List all docx elements
officecli help docx <element> # Full element schema (e.g. paragraph, field, numbering, watermark, toc)
officecli help docx <verb> <element> # Verb-scoped (e.g. add field, set section)
officecli help docx <element> --json # Machine-readable schema
```
Help is pinned to the installed CLI version. When this skill and help disagree, **help is authoritative**.
## Mental Model
A `.docx` is a ZIP of XML parts (`document.xml`, `styles.xml`, `numbering.xml`, `header*.xml`, `footer*.xml`, `comments.xml`, …). Everything the user sees — headings, tables, page numbers, TOC, tracked changes — is XML inside that ZIP. `officecli` gives you a semantic-path API (`/body/p[1]/r[2]`) over it, so you almost never touch raw XML; when you must, use `raw-set` (see the XML appendix).
## Shell & Execution Discipline
docx paths contain `[]`; some prop values contain `$`. Both are shell metacharacters. Escaping happens at three layers — keep them separate.
1. **Shell.** ALWAYS quote element paths: `"/body/p[1]"`, not `/body/p[1]` (zsh/bash glob `[N]`). Single-quote any value containing `$`: `--prop text='$50M'` — at any length, the whole value inside one pair of single quotes. Unquoted `$50M` is stripped to `M`; mixing `'…$var…'` and `"…$50…"` on one long string is where the `$50` silently vanishes.
2. **CLI (`text=`).** The two-char escapes `\n` and `\t` ARE interpreted in `--prop text=` — `\n` becomes a `<w:br/>` soft line break, `\t` a `<w:tab/>` — consistently across docx / pptx / xlsx. Double them (`\\n`) for a literal backslash-n (rarely wanted). This applies to row-level table `c1…cN` shortcuts too (`\n` → `<w:br/>` within the cell).
3. **JSON (batch).** A real newline can also be passed as `"\n"` in the JSON string of a `batch` heredoc; same result.
If in doubt, `view text` after writing and compare character-for-character.
**Incremental execution.** `officecli` mutates the file on every call. Run commands one at a time and check each exit code — a 50-command script that fails at command 3 cascades silently. After any structural op (new style, table, TOC, section break) run `get` on it before stacking more.
**Open/save lifecycle:** `officecli open <file>` at the start, `officecli save <file>` at the end to flush to disk — `save` only writes and leaves the resident warm for follow-up edits; reach for `officecli close <file>` only to release the resident on a one-shot handoff. Both are always safe (never error or lose work). For many paragraphs of one style, use `batch` (one pass for the whole array). **Flush only at the non-officecli boundary:** officecli's own reads always see your edits; run `save`/`close` only before a non-officecli program reads the file (python-docx, Word, a renderer, delivery).
**`$FILE` convention.** All commands use `"$FILE"` — set it once (`FILE="your-doc.docx"`). Never copy a literal `doc.docx` / `review.docx` into output — always substitute your actual target.
## Requirements for Outputs
Deliverable standards every document MUST meet — know these before reaching for a command.
**Clear hierarchy.** Every non-trivial document has Title → Heading 1 → Heading 2 → body, not a wall of unstyled `Normal` paragraphs. If `view outline` shows one flat list, the hierarchy is missing.
**Explicit heading sizes** (Word default style sizes drift between templates): **H1 ≥ 18pt** (20pt for long reports), H2 = 14pt bold, H3 = 12pt bold, body = 11–12pt, line spacing 1.15–1.5x. Prefer `style=Heading1` over inline sizes so a retheme touches the definition once — but set explicit sizes when you can't trust the template's styles.
**One body font, one accent.** One readable body font (Calibri, Cambria, Georgia, Times New Roman); accent color for heading emphasis or table headers, not rainbow formatting.
**Spacing through properties.** Use `spaceBefore` / `spaceAfter` on paragraphs. Rows of empty paragraphs break pagination and are flagged by `view issues`.
**Typographic quality.** New content uses curly quotes (`'` `'` `"` `"`), not ASCII — Unicode directly or XML entities (`&#x2018;`/`&#x2019;`/`&#x201C;`/`&#x201D;`) inside `raw-set`. En-dash `–` for ranges (`2024–2026`), em-dash `—` for parenthetical breaks.
**Headers, footers, page numbers on any document > 1 page.** Page numbers go through a live `PAGE` field (`--prop field=page`), never the literal text "Page 1" — the CLI injects `<w:fldChar>` for you (see Headers & Footers).
**Preserve existing templates.** When editing a file that already has a look, match it — existing conventions override these guidelines.
### Visual delivery floor (applies to EVERY document)
Before declaring done, run `officecli view "$FILE" html` and Read the returned HTML path to confirm ALL of these:
- **No placeholder tokens rendered as data.** `$xxx$`, `{var}`, `{{name}}`, `<TODO>`, `lorem`, `xxxx` must never appear in a heading, body, cover, TOC, caption, header, or footer. A literal `{name}` meant for a human to fill belongs inside a visible instruction paragraph ("Replace `{name}` before sending"), never as finished content.
- **No truncated titles or overflowing cells.** Widen the column or set `wrapText` rather than trimming content.
- **TOC present when the document has 3+ headings** (`--type toc`).
- **Cover page ≥ 60% filled, last page ≥ 40% filled.** Pad a thin cover with subtitle / author / date / scope / key highlights; pad a "Thank you" last page with conclusion / next steps / contact / legal.
- **No `\$`, `\t`, `\n` literals in document text.** If `view text` shows these, a shell-escape layer leaked — delete the paragraph and re-enter it.
If any fails, STOP and fix before declaring done.
## Common Workflow
Six steps. Every non-trivial build follows this shape.
1. **Open the file.** `officecli open "$FILE"` (resident default). New file: `officecli create "$FILE"` first.
2. **Orient.** Existing file: `officecli view "$FILE" outline` — heading tree, section count, whether a TOC / watermark / tracked changes already exist. Never edit blind.
3. **Build incrementally.** Structural first, content next, formatting last: styles & numbering defs → sections / page setup → headings & body → tables / images / fields / TOC → headers / footers → comments. After each structural op, `get` it back before stacking on top.
4. **Format to spec.** Explicit heading sizes, spacing, widths, alignment, tabs, list indents — formatting is part of the deliverable, not optional polish.
5. **Save, then trust structure over cached text.** `officecli save "$FILE"` writes the XML. TOC / PAGE / NUMPAGES / SEQ / PAGEREF fields carry **cached values** that may be stale or empty until a human recalculates (F9 in Word). Confirm fields *exist* (`get --depth 3` finds `<w:fldChar>`) rather than trusting the visible text.
6. **QA — assume there are problems.** You are done after one fix-and-verify cycle finds zero new issues, not when your last command exited 0. See QA.
## Quick Start
Minimal viable docx: a heading, a body paragraph, a subheading, and a footer with a live page-number field. Adapt, don't copy-paste — your file, your content.
```bash
FILE="review.docx"
officecli create "$FILE"
officecli open "$FILE"
officecli add "$FILE" /body --type paragraph --prop text="Q4 2026 Review" --prop style=Heading1 --prop size=20pt --prop bold=true --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Revenue grew 18% year-over-year, ahead of plan." --prop size=11pt --prop spaceAfter=8pt
officecli add "$FILE" /body --type paragraph --prop text="Key Drivers" --prop style=Heading2 --prop size=14pt --prop bold=true --prop spaceBefore=12pt --prop spaceAfter=6pt
officecli add "$FILE" /body --type paragraph --prop text="Enterprise renewals, upsell, and a new EMEA region." --prop size=11pt
officecli add "$FILE" / --type footer --prop type=default --prop size=9pt --prop text="Page " --prop field=page
officecli set "$FILE" "/footer[1]/p[1]" --prop align=center
officecli save "$FILE"
officecli validate "$FILE"
```
Verified: `validate` returns `no errors found`; `get /footer[1] --depth 3` shows the 5-run PAGE field chain (begin / instrText / separate / cached value / end).
## Reading & Analysis
Start wide, then narrow. `outline` tells you what's already there; jump into `view text` / `get` / `query` once you know where to look.
```bash
officecli view "$FILE" outline # heading tree, section count, table/image counts, watermark, tracked-changes presence — orient here first
officecli view "$FILE" html # Read the returned HTML path: first visual check after a batch of edits (hierarchy, empty-para spacing, missing TOC)
officecli view "$FILE" text --start 1 --end 80 # text for content QA; paths shown as [/body/p[N]] so you can jump back with get
officecli view "$FILE" annotated # values + style/font/size + warnings per run
officecli view "$FILE" stats # paragraph counts, font usage, style distribution
officecli view "$FILE" issues # empty paras, missing alt text, spacing anomalies
```
`officecli watch "$FILE"` keeps a live preview running for the human user to open at their discretion — agent self-check uses `view html`. Final visual verification is the user opening the `.docx` in Word / WPS / Pages.
**Inspect one element.** XPath-style semantic paths (1-based). Always quote — shells glob `[N]`. Use `[last()]` (with parens) for the last element; `[last]` errors. Add `--json` for machine output.
```bash
officecli get "$FILE" / # document root: metadata, page setup
officecli get "$FILE" "/body/p[1]" # one paragraph
officecli get "$FILE" "/body/p[1]/r[1]" # one run (character-level formatting)
officecli get "$FILE" "/body/tbl[1]" --depth 3 # table with rows and cells
officecli get "$FILE" "/footer[1]" --depth 3 # footer — check for fldChar
officecli get "$FILE" "/styles/Heading1" # style definition
officecli get "$FILE" /numbering --depth 2 # numbering abstractNum + num bindings
```
**Query across the document.** CSS-like selectors, for systematic checks rather than hand-walking. Operators: `=`, `!=`, `~=` (contains), `>=`, `<=`, `[attr]` (exists). Full reference: `officecli query --help`.
```bash
officecli query "$FILE" 'paragraph[style=Heading1]' # all H1s
officecli query "$FILE" 'p:contains("quarterly")' # text match
officecli query "$FILE" 'p:empty' # empty paragraphs (clutter)
officecli query "$FILE" 'image:no-alt' # accessibility gaps
officecli query "$FILE" 'paragraph[size>=24pt]' # numeric comparison
officecli query "$FILE" 'field[fieldType!=page]' # fields other than PAGE
```
`query --json` wraps results in `.data.results[]` — `jq '.data.results | length'` to count.
**Large documents.** Navigate by heading with `view outline` and jump with `query`; don't dump the whole body into context.
## Creating & Editing
Verbs: `add` (new element), `set` (change a prop), `remove`, `move`, `swap`, `batch`, `raw-set` (last-resort XML). Ninety percent of a build is paragraphs, runs, tables, a couple of images, a TOC, and a footer.
### Paragraphs, runs, styles
A paragraph (`p`) is a block; a run (`r`) is a span of consistent character formatting inside it. Set paragraph-level props (style, alignment, spacing, indent) on the `p`; set font / size / color / bold on the `r`.
```bash
officecli add "$FILE" /body --type paragraph --prop text="Executive Summary" --prop style=Heading1 --prop size=18pt --prop bold=true --prop spaceAfter=12pt
officecli set "$FILE" "/body/p[1]/r[1]" --prop color=1F4E79
```
Use `spaceBefore` / `spaceAfter` for vertical spacing — never chains of empty paragraphs. For left indent use `--prop indent=720` (twips), `firstLineIndent=360` for first line, `hangingIndent=720` for hanging; leading spaces fire `view issues`.
### Tables
Tables are `/body/tbl[N]` with rows `tr[N]` and cells `tc[N]`. Add with row/column counts, then fill.
```bash
officecli add "$FILE" /body --type table --prop rows=4 --prop cols=3 --prop width=100%
officecli set "$FILE" "/body/tbl[1]/tr[1]" --prop header=true --prop c1=Quarter --prop c2="Revenue" --prop c3="Growth"
officecli set "$FILE" "/body/tbl[1]/tr[1]/tc[1]/p[1]/r[1]" --prop bold=true
```
Row-level `set` supports `height`, `header`, and `c1 / c2 / … / cN` text shortcuts (`cN` generalises to any column count). Cell formatting (bold, fill, color) goes on the cell's paragraph / run — **not** row-level. For per-cell borders, set cell-level `border.*` on the `tc` (`--prop border.bottom="single;6;000000;0"`), or paragraph-level `pbdr.*` on the inner paragraph.
**Horizontal rule = a paragraph bottom border, never a 1-row table.** A table-as-divider renders as an empty min-height box (worst in headers/footers). Use `pbdr.bottom` (`STYLE;SIZE;COLOR`) on the paragraph instead:
```bash
officecli set "$FILE" "/body/p[3]" --prop pbdr.bottom="single;6;2E75B6"
```
### Lists (bullets, numbered, multi-level)
For single-level bullets/numbers, set `listStyle` on the paragraph (`listStyle` is a paragraph prop, NOT a run prop — common mistake):
```bash
officecli add "$FILE" /body --type paragraph --prop text="First item" --prop listStyle=bullet
```
For multi-level (legal-style 1 / 1.1 / 1.1.1), add an `abstractNum`, then a `num`, then reference the `numId` per paragraph:
```bash
officecli add "$FILE" /numbering --type abstractnum --prop format=decimal # → abstractNum id=0
officecli add "$FILE" /numbering --type num --prop abstractNumId=0 # → num id=1
officecli add "$FILE" /body --type paragraph --prop text="Section one" --prop numId=1 --prop ilvl=0
```
IDs are 0-based: the first `abstractNum` is id=0; the `num` references it via `abstractNumId=0` and is itself assigned id=1. A non-existent `abstractNumId` errors, so check ids after creating. Verify with `officecli query "$FILE" 'paragraph[numId>0]'`. See `help docx abstractnum` / `help docx num` for level and format options.
### Tab stops (signature lines, leader rows)
Tab stops are a first-class `tab` child of the paragraph; `pos` accepts `6in`/`6cm`/twips, `val` ∈ `left`/`center`/`right`, `leader` ∈ `none`/`dot`/`hyphen`/`underscore`. See `help docx tab`.
```bash
officecli add "$FILE" "/body/p[1]" --type tab --prop pos=6in --prop val=right --prop leader=dot
```
**Leader caveat.** `leader=dot` alone emits no dots — the leader renders only when a real `<w:tab/>` character sits in a run between the text and the tab stop. Put one there with `\t` in the text: define the stop (`add tab --prop pos=6in --prop val=right --prop leader=dot`), then `--prop text="Chapter 1\t12"` — the `\t` becomes the `<w:tab/>` and dots fill to the right-aligned page number. (Literal `text="Chapter 1 ......... 12"` also ships, but a real tab stop aligns cleanly.)
### Fields (PAGE / NUMPAGES / DATE / MERGEFIELD / REF)
Fields are live values computed at render time. `fieldType` picks the field; `name` supplies the target (merge name or `ref` bookmark); `format` / `instr` add switches.
| Field | Use | Example |
|---|---|---|
| `page` | current page number | `--prop field=page` on footer, or `--prop fieldType=page` inline |
| `numpages` | total pages | `--prop field=numpages` / `--prop fieldType=numpages` |
| `date` | today | `--prop fieldType=date --prop format='yyyy-MM-dd'` |
| `mergefield` | template merge token | `--prop fieldType=mergefield --prop name=CustomerName` |
| `ref` | cross-reference to a bookmark | `--prop fieldType=ref --prop name=bookmarkName` |
Full `fieldType` enum (30+ values incl. `pageref`, `seq`, `styleref`, `docproperty`, `createdate`, …) is in `help docx field`. **There is NO `fieldInstr` fieldType** — use the `instruction` prop for raw field instruction text when typed shortcuts fall short. Picture switches (`MERGEFIELD Amount \# "#,##0.00"`, `DATE \@ "yyyy年MM月"`) go via `--prop instruction='…'` (mergefield's `format` prop is ignored with a warning — use `instruction`).
```bash
officecli add "$FILE" "/body/p[3]" --type field --prop fieldType=mergefield --prop name=customer_name
# Renders «customer_name» — visible placeholder, replaced in Word at mail-merge time.
```
**MERGEFIELD templates: never render placeholder literals.** A `{{customer_name}}` or `$NAME$` shown as body text is a failed template the recipient sees — insert a real MERGEFIELD (above), or confine literal tokens to an obvious instruction paragraph. Confirm with `query 'field[fieldType=mergefield]'`.
**SEQ / PAGEREF / TOC field values.** officecli doesn't store rendered field values at write time. Recompute by what each path needs:
- **SEQ numbering** (`Figure 1/2/3`): `officecli set "$FILE" / --prop recalcFields=seq` counts SEQ fields in body document order and writes the cached values (`evaluated` flips true; switches/formats in `help docx document`). Heading-relative `\s` and SEQ in headers/footers defer to Word.
- **PAGE / PAGEREF / NUMPAGES / TOC page numbers** need pagination, which officecli has no engine for — `officecli set "$FILE" /settings --prop updateFields=true` defers them to Word on open.
Use both on a multi-figure document. Academic papers: see the `officecli-academic-paper` skill.
### Headers & Footers (page numbering)
Single-command pattern — the CLI injects `<w:fldChar>`, so you never compose the field by hand:
```bash
# Empty first-page footer — auto-enables differentFirstPage so the cover has no page number
officecli add "$FILE" / --type footer --prop type=first --prop text=""
# Default footer with live page number
officecli add "$FILE" / --type footer --prop type=default --prop align=center --prop size=9pt --prop text="Page " --prop field=page
```
When both exist, the default footer is `/footer[2]`; alone it is `/footer[1]`. **Verify**: `get --depth 3` must show `fldChar` children, not just a run with literal `"Page"` (`view outline` prints "Footer: Page" for both live fields AND static text — don't rely on it). Do NOT `set --prop differentFirstPage=true` — that prop is unsupported (exit 2 with `WARNING: UNSUPPORTED`; the element itself is still created — check `success` in `--json`); adding a first-type footer flips the bit. For composite **Page X of Y**, see recipe (b).
### Table of Contents
For any document with 3+ headings, first verify that the requested range has real sources. A TOC source must use a built-in Heading style or a custom paragraph style with `outlineLvl`; bold or large Normal text is not a source. If no eligible source exists, stop and fix the heading structure instead of inserting a TOC that renders "Error! No table of contents entries found."
```bash
# Built-in Heading styles are TOC sources.
officecli add "$FILE" /body --type paragraph --prop text="Introduction" --prop style=Heading1
# Custom paragraph styles need an outline level (0 = Heading 1).
officecli add "$FILE" /styles --type style --prop id=ThesisH1 --prop type=paragraph --prop outlineLvl=0
# Add the TOC only after its sources exist.
officecli add "$FILE" /body --type toc --prop levels="1-3" --prop title="Table of Contents" --prop hyperlinks=true --index 0
```
Page numbers need pagination; OfficeCLI cannot calculate TOC page numbers itself. Set `updateFields=true` so Word recomputes the TOC (and all fields) on open. Without a Word-compatible field engine, report the TOC as dynamic and uncomputed; do not claim ready page numbers or guess a static TOC.
```bash
officecli set "$FILE" /settings --prop updateFields=true
```
Address the TOC directly with `/toc[1]` or `/tableofcontents` for `get`/`set`/`remove`.
### Images
Pictures go inside a run. Alt text is mandatory for accessibility — pass `alt` directly at create time:
```bash
officecli add "$FILE" "/body/p[5]" --type picture --prop src=logo.png --prop width=1.5in --prop alt="Acme logo"
```
Confirm `officecli query "$FILE" 'image:no-alt'` is empty before delivery.
### Charts
For data, add a **native chart** — editable, themeable, accessible, re-renders in Word — never a flat PNG screenshot of a chart. `data="Label:v1,v2,…"` per series; one `data=` per series (or `series1=`/`series2=`).
```bash
officecli add "$FILE" /body --type chart --prop chartType=bar --prop title="Revenue by Region" --prop categories="EMEA,APAC,Americas" --prop data="2026:120,150,180"
```
`chartType` ∈ bar / column / line / pie / area / scatter (`help docx chart` for axis/legend/series styling). A PNG via `--type picture` is only a fallback for an exotic chart officecli can't build.
### Hyperlinks and bookmarks
External links go via `hyperlink`:
```bash
officecli add "$FILE" "/body/p[2]" --type hyperlink --prop url="https://example.com" --prop text="our site"
```
**Internal links** (to a bookmark) use `--prop anchor=bookmarkName` — not a `#fragment` in `url`:
```bash
officecli add "$FILE" "/body/p[2]" --type hyperlink --prop anchor=chapter1 --prop text="See Chapter 1"
```
Pairing a `PAGEREF` field with visible text is the alternative. See `help docx hyperlink` / `help docx bookmark`.
### Sections and page setup
Document root `/` carries page setup (`pageWidth`, `pageHeight`, margins, in twips). Multi-section documents (landscape insert, columns) add a `section` break — see `help docx section`. Both camelCase (`pageWidth`, canonical) and lowercase alias (`pagewidth`) are accepted; prefer camelCase.
```bash
officecli set "$FILE" / --prop pageWidth=12240 --prop pageHeight=15840 --prop marginTop=1440 --prop marginLeft=1440
# Newspaper-style multi-column flow (columnSpace in twips; 720 = 0.5in):
officecli set "$FILE" / --prop columns=2 --prop columnSpace=720
```
### Forcing page breaks
Use exactly one page-break mechanism per logical boundary. The default is `pageBreakBefore=true` on the target heading. Alternatively, insert one explicit `pagebreak` before the heading. Never combine both mechanisms for the same boundary: the resulting double break can create a blank page. Never set `pageBreakBefore` on `p[last()]` immediately after adding a `pagebreak`.
```bash
# Default: apply the break directly to the heading.
officecli add "$FILE" /body --type paragraph --prop text="Introduction" --prop style=Heading1 --prop pageBreakBefore=true
```
`--prop break=newPage` is an alias for `pageBreakBefore=true`. Preview with `view html` and check for blank pages.
### Report-level recipes
Patterns that come up on every long-form report. Each has been executed and `validate`-passed.
**(a) Rich cover page — hit the ≥ 60% filled floor.** Stack a confidentiality banner, title, subtitle, client/project/date block, and a key-themes strip, then force the next section onto a new page:
```bash
officecli add "$FILE" /body --type paragraph --prop text="CONFIDENTIAL — CLIENT USE ONLY" --prop align=center --prop size=9pt --prop color=C00000 --prop spaceAfter=24pt
officecli add "$FILE" /body --type paragraph --prop text="Strategic Growth Review" --prop style=Title --prop size=32pt --prop bold=true --prop align=center --prop font=Cambria --prop spaceAfter=8pt
officecli add "$FILE" /body --type paragraph --prop text="FY26 Outlook and Scenario Planning" --prop italic=true --prop size=16pt --prop align=center --prop spaceAfter=36pt
officecli add "$FILE" /body --type paragraph --prop text='Prepared for: Acme Corp. Leadership Team' --prop align=center --prop size=11pt
officecli add "$FILE" /body --type paragraph --prop text='Engagement: 2026-04 — 2026-06' --prop align=center --prop size=11pt --prop spaceAfter=36pt
officecli add "$FILE" /body --type paragraph --prop text="Key themes: 1) margin resilience, 2) EMEA expansion, 3) capital allocation." --prop align=center --prop italic=true --prop size=10pt
officecli add "$FILE" /body --type paragraph --prop text="Executive Summary" --prop style=Heading1 --prop pageBreakBefore=true
```
**(b) Page X of Y footer — composite PAGE + NUMPAGES.** Add the footer paragraph, then three child ops build `Page <X> of <Y>` live. The official `help docx footer` recipe.
```bash
officecli add "$FILE" / --type footer --prop type=default --prop text="Page " --prop align=center --prop size=9pt
officecli add "$FILE" "/footer[1]/p[1]" --type field --prop fieldType=page
officecli add "$FILE" "/footer[1]/p[1]" --type run --prop text=" of "
officecli add "$FILE" "/footer[1]/p[1]" --type field --prop fieldType=numpages
officecli get "$FILE" "/footer[1]/p[1]" --depth 1 | grep -o fldChar | wc -l # expect ≥ 4; use grep -o ... | wc -l, NOT grep -c (single-line XML returns 1)
```
**(c) Header row with fill and white bold text.** Order matters — populate header cell text FIRST (runs don't exist in empty cells; a `set …/tc[N]/p[1]/r[1]` on an empty cell errors "No r found"), THEN cell fill, THEN run formatting:
```bash
officecli add "$FILE" /body --type table --prop rows=5 --prop cols=4 --prop width=100%
officecli set "$FILE" "/body/tbl[1]/tr[1]" --prop header=true --prop c1=Quarter --prop c2=Revenue --prop c3=Growth --prop c4=Status
for col in 1 2 3 4; do
officecli set "$FILE" "/body/tbl[1]/tr[1]/tc[$col]" --prop fill=1F4E79
officecli set "$FILE" "/body/tbl[1]/tr[1]/tc[$col]/p[1]/r[1]" --prop bold=true --prop color=FFFFFF
done
for row in 3 5; do for col in 1 2 3 4; do
officecli set "$FILE" "/body/tbl[1]/tr[$row]/tc[$col]" --prop fill=D9E2F3 # zebra stripe
done; done
```
**(d) Financial table — right-align numbers, bold totals, bottom border on total row.**
```bash
for row in 2 3 4 5; do for col in 2 3 4; do
officecli set "$FILE" "/body/tbl[1]/tr[$row]/tc[$col]/p[1]" --prop align=right
done; done
for col in 1 2 3 4; do
officecli set "$FILE" "/body/tbl[1]/tr[5]/tc[$col]/p[1]/r[1]" --prop bold=true
officecli set "$FILE" "/body/tbl[1]/tr[4]/tc[$col]/p[1]" --prop pbdr.bottom="single;6;000000;0"
done
```
**(e) Cell with multiple bullets (SWOT / risk matrix).** `c1="a\nb"` gives a `<w:br/>` line break within **one** paragraph — fine for plain multi-line text, but bullets need separate paragraphs. Seed the first via `set c1=`, then `add paragraph` (with `listStyle=bullet`) under the cell per subsequent bullet:
```bash
officecli set "$FILE" "/body/tbl[1]/tr[1]" --prop c1="Installed base of 18k enterprise seats"
officecli add "$FILE" "/body/tbl[1]/tr[1]/tc[1]" --type paragraph --prop text="Margin structure above peer median" --prop listStyle=bullet
officecli set "$FILE" "/body/tbl[1]/tr[1]/tc[1]/p[1]" --prop listStyle=bullet
```
If the seeded line lands at the bottom, re-order: `officecli move "$FILE" "/body/tbl[1]/tr[1]/tc[1]/p[N]" --index 0`.
**(f) TOC without a field engine.** A CLI-only pipeline cannot calculate reliable page numbers. Keep the live TOC, set `updateFields=true`, and tell the recipient to open the document in Word or another compatible field engine. Do not replace it with guessed static page numbers.
### Template delivery — separating Template Notes from end-user content
HR / legal / vendor templates carry internal-only guidance ("replace `{{CompanyName}}`") that must NOT ship. Two working patterns:
- **Trailing "Template Notes" section** under a clear `Heading 1` ("Template Notes for HR Users") with all instructions below it; before distribution, `remove` from the heading downward (locate with `query 'paragraph[style=Heading1]:contains("Template Notes")'`).
- **Bookmark-bounded internal section** between `__template_notes_start` / `_end` bookmarks; at delivery `raw-set` removes everything between the anchors.
Delivery gate for templates: after removal, `query 'p:contains("Template Notes")'` AND `query 'p:contains("{{")'` both return empty. If a notes paragraph survives, a downstream employee reads internal language.
### Advanced / specialty topics (skip if you are writing a report)
Reports, memos, letters, proposals, and HR templates don't need this. Keep reading only if your document is academic (equations, footnotes, bibliography), reviewed (comments, tracked changes), or marked (watermark).
**Equations and footnotes.** `--type equation` takes LaTeX — `\frac`, `\sum`, Greek, `\mathit`, `\mathcal` all render. By default it creates a standalone `/body/oMathPara[N]` display block; pass `--prop mode=inline` with a paragraph parent path (`add "/body/p[N]" --type equation --prop formula=… --prop mode=inline`) to drop an inline `<m:oMath>` into running text. Footnotes auto-number by paragraph index. Bibliography hanging indent: `firstLineIndent=-720 indent=720` per entry.
```bash
officecli add "$FILE" /body --type equation --prop formula="\\frac{a}{b} + \\sum_{i=1}^{n} x_i"
officecli add "$FILE" "/body/p[3]" --type footnote --prop text="See Appendix A for methodology."
```
**Comments and tracked changes.** Bulk accept/reject: `set "$FILE" /revision --prop revision.action=accept` (or `--prop revision.action=reject`); narrow with a selector like `/revision[@author=Alice]` or `/revision[@type=ins]`. Locate individual changes with `query ins` and `query del` (`trackedchange` is not a selector). Create tracked changes on a run with `--prop revision.type=ins|del --prop revision.author=…` (`help docx run` for the full `revision.*` set — `format`/`moveFrom`/`moveTo` too). Add a comment: `add "/body/p[4]" --type comment --prop author=… --prop text=…`; reply-thread it with `--prop parentId=N` and mark it resolved with `set "/comments/comment[N]" --prop done=true` (resolve rather than delete to keep the audit trail — `query 'comment[done=false]'` then lists what's still open). Prop schema: `help docx comment` / `help docx run`.
**Watermark.** `add / --type watermark --prop text="DRAFT" --prop color=BFBFBF --prop opacity=0.8` in one command (default opacity 0.5); `set /watermark --prop opacity=…` adjusts it later.
**When to switch skills.** Stay in docx for chapter drafts, ≤ 3 footnotes, ≤ 2 equations, no bibliography/cross-refs. Switch to **`academic-paper`** for citation styles (APA / Chicago / IEEE / GB 7714), in-text↔reference auto-linking, numbered equations with `\ref`, "List of Figures", or auto-updating cross-refs. Switch to **`officecli-word-form`** when the document's purpose is **data capture** — fillable forms, contracts with user-fill slots, questionnaires, mail-merge templates (`<w:sdt>` content controls, `<w:ffData>`, `documentProtection=forms`).
### Raw-set escape hatch (L1 / L2 / L3)
Three tiers of precision; use the lowest that does the job.
- **L1 — high-level props** (`--prop text=…`, `--prop style=Heading1`): your default. Covers 80%.
- **L2 — dotted-attr fallback** (`pbdr.top=`, `ind.left=`, `shd.fill=`, `padding.top=`, `font.size=`): when L1 lacks the knob. Example: `--prop pbdr.bottom="single;6;1F4E79;0"`. Emits schema-valid XML.
- **L3 — `raw-set` with XML**: last resort, no schema protection. Use for internal hyperlinks, composite fields, and other shapes the typed verbs can't express (see XML appendix).
Borders use the format `style;size;color;space`: `single;4;FF0000;1`. Hex colors never start with `#`: `FF0000`. Scheme color names (`accent1..6`, `dark1`/`dark2`, `light1`/`light2`, `hyperlink`) are accepted anywhere a hex color is — prefer hex for stable colors across themes.
## QA (Required)
**Assume there are problems — QA is a bug hunt, not a confirmation step.** Your first document is almost never correct; zero issues on first inspection means you weren't looking hard enough. Headings look fine until `view outline` shows an H3 directly under an H1; the footer shows "Page 1" until `get --depth 3` reveals a static run, not a field.
### Minimum cycle before "done"
1. `officecli view "$FILE" issues` — empty paras, missing alt text, formatting anomalies.
2. `officecli view "$FILE" outline` — heading hierarchy (no H1 → H3 skips), TOC presence, section count.
3. `officecli view "$FILE" text --max-lines 400` — typos, stray `\$`/`\t`/`\n` literals, placeholder tokens.
4. `officecli validate "$FILE"` — schema check (the Delivery Gate re-runs this on the closed, on-disk file).
5. **Visual pass — whole document as a contact sheet** (vision-capable agents only — if you cannot interpret images, skip this step: steps 1–4 are your ceiling, and flag the document "not visually verified" at handoff). `officecli view "$FILE" screenshot --grid auto -o /tmp/sheet.png`, then Read it. `--grid auto` tiles **every page** into one image (auto column count; `--grid 4` to force) — you *see* pagination, blank pages, heading rhythm, lopsided margins, and TOC/cover placement, not just the DOM. Windows+Word renders each page through real Word; elsewhere HTML. If the screenshot fails, fall back to `view html` and flag cross-page breaks / alignment / rhythm as "not visually verified". Thumbnails only **locate**: confirm any fine call (column alignment, line spacing, indents, dark-on-dark, caption placement) on the suspect page at full resolution with `screenshot --page N` (no `--grid`; real Word on Windows). "validate pass" is not delivery; "looks like a real document" is.
6. If anything failed, fix, then **rerun the full cycle** — one fix commonly creates another problem.
### Delivery Gate (run before handing off — any failure = REJECT, do NOT deliver)
Copy-paste, set `FILE`, and refuse to declare done until every gate prints OK.
```bash
FILE="your-file.docx"
# Gate 1 — schema.
officecli close "$FILE" 2>/dev/null
officecli validate "$FILE" | grep -q "no errors found" || { echo "REJECT Gate 1: validate failed"; exit 1; }
echo "Gate 1 OK"
# Gate 2 — token leak (shell-escape / template tokens / literal \$ \t \n). grep -c never false-PASSes.
LEAK=$(officecli view "$FILE" text | grep -cE '(\$[A-Za-z_]+\$|\{\{[^}]+\}\}|<TODO>|xxxx|lorem|\\[\$tn])')
[ "$LEAK" -eq 0 ] && echo "Gate 2 OK" || { echo "REJECT Gate 2: $LEAK leak line(s)"; officecli view "$FILE" text | grep -nE '(\$[A-Za-z_]+\$|\{\{[^}]+\}\}|<TODO>|xxxx|lorem|\\[\$tn])'; exit 1; }
# A TOC placeholder is valid before a Word-compatible field engine updates it; confirm the TOC field and updateFields setting structurally instead.
# Gate 3 — live PAGE field exists when a footer is expected.
FLD=$(officecli query "$FILE" 'field[fieldType=page]' --json | jq '.data.results | length')
[ "$FLD" -ge 1 ] && echo "Gate 3 OK" || { echo "REJECT Gate 3: no live PAGE field"; exit 1; }
echo "Delivery Gate PASS"
```
### Field / cached-value spot-check
Fields carry cached values that may be stale or empty at write time — confirm existence by **structure, not text**.
- **Footer PAGE:** `get /footer[N] --depth 3` lists the begin / instrText / separate / cached / end run chain — ≥ 5 runs for one PAGE, ≥ 11 for composite "Page X of Y". A single run with text `"Page"` = field missing; re-add with `--prop field=page`.
- **TOC:** `get /toc[1] --depth 2` shows field structure. Page numbers may read `1 1 1 1` or `Update field to see…` until recalculated (see §Table of Contents — set `updateFields=true`).
- **MERGEFIELD:** `query 'field[fieldType=mergefield]'` — one per slot, no literal `{{name}}` elsewhere.
### Honest limit
`validate` catches schema errors, not design errors — a document can pass it with wrong heading hierarchy, fake-Heading-1 sizes, placeholder tokens as body text, or an empty first-page footer on a coverless document. The contact-sheet visual pass (`screenshot --grid`) and the field-structure check are how you catch what validation can't.
### QA display notes (don't chase these)
- `view text` shows `"1."` for every numbered list item regardless of rendered number — actual output increments correctly.
- `view issues` flags "body paragraph missing first-line indent" on cover paragraphs, centered headings, list items, bibliography entries — first-line indent is only required for APA/academic body text; on block-style professional documents these are expected.
## Known Issues & Pitfalls
When something "looks broken", attribute it before chasing: **[AGENT-ERROR]** the document is wrong (fix it) · **[RENDERER-BUG]** the document is correct, a viewer renders it differently (don't chase) · **[SKILL gap]** the skill didn't teach the rule (file an issue).
### Renderer quirks (cross-viewer, [RENDERER-BUG] — don't chase)
Before calling a color/field/chart broken, open the file in the user's target viewer; if it looks correct there it's a viewer quirk.
- **PAGE field may render literal "Page"** (no number) until recalculated — judge by `fldChar` presence, not the digit.
- **TOC cached page numbers may read "1 1 1 1"** until F9.
- **Pie / doughnut fill may collapse to one color** in some viewers (column/bar render fine).
- **Form-control checkboxes may render double-boxed**; **OMML equation baselines** may shift across viewers (XML identical).
### Common pitfalls
| Pitfall | Correct approach |
|---|---|
| `--index` vs `[N]` | `--index` is 0-based; `[N]` paths are 1-based |
| Multiple `add --index N` with the same N | Each insert shifts later content down; reusing N puts later items BEFORE earlier ones — insert in reverse order, or `move --after/--before` anchored on `paraId` |
| Unquoted `[N]` in zsh/bash | Quote every path: `"/body/p[1]"` |
| `[last]` as predicate | Must be `[last()]` with parens |
| Raw twips in spacing | Use unit-qualified values: `12pt`, `0.5cm`, `1.5x` |
| Empty paragraphs for spacing | Use `spaceBefore` / `spaceAfter` |
| Row-level `set` for cell formatting | Row `set` only supports `height`, `header`, `c1..cN` text; format goes on the cell paragraph / run |
| `listStyle` on a run | It's a paragraph property |
| Indent via leading spaces | `indent=720` / `firstLineIndent=360` / `hangingIndent=720` (dotted `ind.left` / `ind.firstLine` also work) |
| Cover page-number suppression via `set differentFirstPage=true` | UNSUPPORTED — add a first-type footer: `--type footer --prop type=first --prop text=""` |
| Need a fresh chapter page | Use `pageBreakBefore=true` on the heading; use an explicit `pagebreak` only as an alternative, never both |
| Multiple bullet paragraphs in one cell | `c1="a\nb"` makes a `<w:br/>` line break (one paragraph); for separate bullet paragraphs use recipe (e) |
| `raw-set` when dotted-attr would work | Prefer L2 dotted-attr over L3 raw-set |
| Next paragraph inherits the previous Heading style | Set explicit `--prop style=Normal` on the following paragraph |
| Modifying a file open in Word | Close it in Word first |
| Echo into batch breaks on `$`/`'` | Heredoc with single-quoted delimiter: `cat <<'EOF' \| officecli batch …` |
## Raw-set XML appendix (L3 patterns)
`raw-set` injects literal OOXML — no schema protection. Element order in `<w:pPr>`: `pStyle`, `numPr`, `spacing`, `ind`, `jc`, `rPr` (last). Smart quotes as entities (`&#x2018;`/`&#x2019;`/`&#x201C;`/`&#x201D;`). Add `xml:space="preserve"` to any `<w:t>` with leading/trailing spaces. RSIDs are 8-digit hex. Use "Claude" as the author for tracked changes/comments unless the user names another.
**Tracked-change insertion / deletion** — prefer the high-level `--prop revision.type=ins|del` on a run; raw-set only for what the typed path can't express (rejecting/restoring another author's change, below). Replace the whole `<w:r>…</w:r>`, never inject tags inside a run; copy the original `<w:rPr>` into both to preserve formatting. Inside `<w:del>` use `<w:delText>` (and `<w:delInstrText>` for instructions):
```xml
<w:r><w:t>The term is </w:t></w:r>
<w:del w:id="1" w:author="Claude" w:date="2026-01-01T00:00:00Z"><w:r><w:delText>30</w:delText></w:r></w:del>
<w:ins w:id="2" w:author="Claude" w:date="2026-01-01T00:00:00Z"><w:r><w:t>60</w:t></w:r></w:ins>
<w:r><w:t> days.</w:t></w:r>
```
When deleting ALL content of a paragraph/list item, also mark the paragraph mark deleted (`<w:del/>` inside `<w:pPr><w:rPr>`) — otherwise accepting changes leaves an empty paragraph. To **reject another author's insertion**, nest your `<w:del>` inside their `<w:ins>`; to **restore their deletion**, add a `<w:ins>` after it (don't modify theirs).
**Internal hyperlink to a bookmark** (prefer the high-level `--prop anchor=` path; raw-set only for custom run styling the command can't express):
```xml
<w:hyperlink w:anchor="chapter1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>See Chapter 1</w:t></w:r></w:hyperlink>
```
**Composite field in one run** (e.g. two fields the single-command path can't compose) — the `fldChar begin / instrText / separate / value / end` chain:
```xml
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:t>1</w:t></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
```
**Comment markers** are siblings of `<w:r>`, NEVER inside one (reply threading and resolved-state are high-level — `--prop parentId=`/`done=`, see Comments above):
```xml
<w:commentRangeStart w:id="0"/><w:r><w:t>annotated text</w:t></w:r><w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
```
Force field recalc on open with `officecli set "$FILE" /settings --prop updateFields=true` (writes `<w:updateFields w:val="true"/>`; covers the layout-dependent fields PAGE / PAGEREF / NUMPAGES / TOC page numbers — no raw-set needed). For SEQ numbering, prefer `set / --prop recalcFields=seq`, which writes correct cached values now without waiting on Word.
### Help pointer
When in doubt: `officecli help docx`, `officecli help docx <element>`, `officecli help docx <verb> <element>`, `--json` for agents. Help is the authoritative schema; this skill is the decision guide.
@@ -0,0 +1,560 @@
---
name: officecli-financial-model
description: "Use this skill when the user wants to build a financial model — 3-statement model, DCF valuation, LBO, SaaS unit economics, sensitivity / scenario analysis, debt schedule, or fundraising projections — in Excel. Trigger on: 'financial model', '3-statement model', 'P&L + BS + CF', 'DCF', 'WACC', 'NPV', 'terminal value', 'LBO', 'debt schedule', 'cash sweep', 'MOIC', 'IRR / XIRR', 'sensitivity table', 'scenario analysis', 'ARR model', 'unit economics', 'CAC / LTV', 'cap table forecast'. Output is a single formula-driven .xlsx. This skill is a scene layer on top of officecli-xlsx — it inherits every xlsx v2 rule (4-color code, visual floor, number formats, cache-drift, Known Issues, Delivery Gate minimum cycle). DO NOT invoke for a simple budget tracker, CSV dump, or operational KPI sheet — route those to officecli-xlsx base."
---
# OfficeCLI Financial-Model Skill
**This skill is a scene layer on top of `officecli-xlsx`.** Every xlsx hard rule — shell quoting, incremental execution, Help-First Rule, visual delivery floor, CFO 4-color code (blue input / black formula / green cross-sheet / yellow-fill assumption), number-format standards (years as text, zero as `-`, `%` one decimal, negatives in parens), assumption-cell discipline, CSV batch import, chart data-feed forms (a/b/c), the 5-gate Delivery cycle, cache-drift guidance, Known Issues (the cross-sheet `!` trap, batch + resident for formulas, renderer caveats) — is **inherited, not re-taught**. This file adds only what a **financial model** requires on top: three-zone architecture, 3 model-type recipes (3-statement / DCF / LBO), sensitivity + scenario protocols, financial-function patterns, circular-reference discipline, and model-specific Delivery Gates 4–6.
When the xlsx base rules cover it, the text here says `→ see xlsx v2 §X`. Read `skills/officecli-xlsx/SKILL.md` first if you have not.
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## Help-First Rule
This skill teaches what a financial model requires, not every CLI flag. When a prop name / alias / enum is uncertain, consult help BEFORE guessing: `officecli help xlsx [element] [--json]`. Help is authoritative for the installed version — when this skill and help disagree, **help wins**. Every `--prop X=` below was verified against `officecli help xlsx <element>`.
## Mental Model & Inheritance
**Inherits xlsx v2.** Read `skills/officecli-xlsx/SKILL.md` first. This skill assumes you know `create` / `open` / `close`, `set` values/formulas, `batch` heredocs for cross-sheet formulas, `/SheetName/A1` paths, named ranges, the 5-gate Delivery cycle, the cross-sheet `!` trap, and that **cross-sheet formulas go non-resident (single batch OR individual `set`), never batch-while-resident**.
## Shell & Execution Discipline
Shell quoting, incremental execution, `$FILE` convention → see xlsx v2 §Shell & Execution Discipline. Same rules: quote every `[N]` path, single-quote any prop containing `$` (every number format here — `$#,##0;($#,##0);"-"` — needs single quotes), no hand-written `\$`/`\t`/`\n`, one command at a time. Examples below use `$FILE` (`FILE="model.xlsx"`).
## Core Principles (identity)
A financial model is an xlsx with a **decision-grade, formula-driven layer**: every output traces an unbroken chain to blue-font assumptions, every statement balances every period, every valuation is re-auditable. Eight deltas on top of a general xlsx:
1. **Three-zone architecture mandatory:** Inputs → Calc → Outputs. Collapsing zones → unauditable.
2. **Assumptions live in cells, never inside formulas.** `=B5*(1+Assumptions!GrowthRate)`, never `=B5*1.05`.
3. **Statements balance every period.** `Assets − Liab − Equity = 0`, `CF.EndingCash = BS.Cash`. Gate 4 fails on `IMBALANCED`.
4. **Hardcodes audited.** Calc sheets carry zero hardcoded numbers; Gate 6 counts.
5. **Sensitivity / scenario is first-class.** 2-axis grid, dropdown `INDEX/MATCH` switch, or Base/Upside/Downside cols. Excel Data Tables not reliably supported — manual grids only.
6. **Cached values on valuation cells load-bearing.** A valuation cell that ships with no cached result (or the `#OCLI_NOTEVAL!` sentinel) sends a blank/wrong number to non-recalculating readers. The evaluator now computes NPV / XNPV / IRR / XIRR; verify the cached value with a readback. Gate 5 spot-checks.
7. **Circularity is a design choice.** Legitimate rings (interest ↔ cash, revolver plug ↔ ending cash) use `calc.iterate=true`. Accidental circularity is broken algebra — never papered with `iterate`.
8. **Named ranges for ≥ 3-use assumptions.** `WACC`, `TaxRate`, `TerminalGrowth`, `ExitMultiple`, `ChurnRate`. Declared-unused names are dead decoration — Gate 6 flags.
### Reverse handoff — when to go BACK to xlsx base
Stay in **xlsx base** for: budget trackers, CSV-to-report dumps, operational KPI sheets, simple templates, cap tables without forecast logic. Use **this skill** only when the ask mentions: 3-statement / DCF / WACC / NPV / TV / LBO / debt schedule / MOIC / IRR / unit economics / ARR roll-forward / sensitivity grid / scenario switch / pro forma.
## Three-zone architecture (hard rule)
Every model in this skill builds on three zones. **Name them, tab-color them, and enforce them with executable audits.** Breaking the zone rule is the single most common cause of an unauditable model.
| Zone | Sheet names (convention) | Tab color | Content | Hardcodes | Formulas |
|---|---|---|---|---|---|
| **Inputs** | `Assumptions`, `Inputs`, `Drivers` | Yellow `FFC000` | Raw drivers: growth rates, margins, tax, WACC, FTE, pricing, working-capital days | Blue `0000FF` on every cell | Allowed only for derived assumptions (e.g. `=MonthlyARPU*12`) |
| **Calc** | `P&L`, `Balance Sheet`, `Cash Flow`, `DCF`, `Debt`, `ARR` | Blue `4472C4` | All derivations and statements | **Zero** (enforced by Gate 6) | Black `000000` for same-sheet, green `008000` for cross-sheet |
| **Outputs** | `Summary`, `Dashboard`, `Sensitivity`, `Returns` | Green `70AD47` | KPIs, sensitivity grids, charts, returns waterfall | Only for labels (non-numeric); Gate 6 counts numeric hardcodes → 0 | Black / green per above |
**Build order is cross-zone-aware.** Assumptions first, then Calc bottom-up on the dependency chain (`IS → BS → CF` for 3-statement; `FCF → WACC → NPV` for DCF), then Outputs last. Building Outputs first caches `0` everywhere and downstream inherits zeros.
**Executable zone audit** (run before Gate 4):
```bash
# Calc zone: zero numeric hardcodes allowed. `cell:not(:has(formula))` selects the literal (non-formula) cells; `cell:has(formula)` selects the formula cells.
HARDCODE=$(officecli query "$FILE" 'cell[type=Number]' --json | jq '[.data.results[] | select(.format.formula == null) | select(.path | test("/(P&L|Balance Sheet|Cash Flow|DCF|Debt|ARR)/"))] | length')
[ "$HARDCODE" -eq 0 ] && echo "Zone audit OK" || { echo "REJECT: $HARDCODE hardcoded numeric cells on Calc sheets — move to Assumptions"; exit 1; }
# Assumptions zone: should be non-zero.
INPUTS=$(officecli query "$FILE" '/Assumptions/cell[type=Number]' --json | jq '[.data.results[] | select(.format.formula == null)] | length')
[ "$INPUTS" -ge 5 ] && echo "Assumptions has $INPUTS hardcoded drivers" || echo "WARN: Assumptions has only $INPUTS inputs"
```
## Print delivery (board / IC / LP)
When the ask contains "print" / "一页" / "董事会" / "投资人" / "IC memo" / "LP update", the print pipeline must emit **only** the Outputs zone. Two artefacts:
```bash
# 1. Print_Area scoped to the Outputs sheet (Summary or Dashboard).
officecli add "$FILE" / --type namedrange --prop name=_xlnm.Print_Area --prop scope=Summary --prop 'refersTo=Summary!$A$1:$H$40'
# 2. Hide every non-Outputs sheet — Print_Area scope alone does NOT stop the print pipeline from emitting every visible sheet.
for S in Assumptions 'P&L' 'Balance Sheet' 'Cash Flow' DCF WACC Debt FCF 'S&U' Exit Returns; do
officecli raw-set "$FILE" /workbook --xpath "//x:sheet[@name='$S']" --action setattr --xml "state=hidden" || true
done
# 3. fit-to-page landscape on Outputs sheet.
officecli raw-set "$FILE" /Summary --xpath "//x:worksheet" --action prepend --xml '<sheetPr xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><pageSetUpPr fitToPage="1"/></sheetPr>'
```
Delete any `Print_Area` set on Calc sheets — conflicting scopes emit multi-page output with Assumptions / statement sheets leaking.
## Build-order & cache-drift rule (critical for 3-statement)
Three facts cause silent wrong numbers: (1) new formulas ship without cached values — Excel recomputes on open, HTML preview / older viewers do not; (2) downstream written in the same sequence as upstream caches `0` from upstream's pre-cache state; (3) cross-sheet `batch` while resident is open deadlocks at 3–5 ops.
**Discipline (every recipe):**
- Build order follows the data chain: `P&L → BS → CF` (3-statement); `FCF → WACC → NPV → Sensitivity` (DCF); `S&U → Debt → P&L → CF → Returns` (LBO).
- After the cross-sheet chain, **cache-refresh pass:** re-issue `set` on every summary / valuation / balance-check cell, non-resident.
- Spot-check: `officecli get "$FILE" /Summary/B2 --json | jq '.data.results[0].format.cachedValue'` returns a plausible non-null value. `null` means Excel will compute on open (OK for delivery). If a cell shows the `#OCLI_NOTEVAL!` sentinel: close residents, re-set; still unevaluated → cache-fallback (§Financial function patterns).
## Recipes — three model types
Each recipe below is **runnable skeleton, not finance theory**. Substitute numbers; don't restructure. All recipes assume `FILE="model.xlsx"` is set and you have run `officecli create "$FILE"` + `officecli open "$FILE"`. Close with `officecli close "$FILE"` at the end.
### Recipe A — 3-statement model (P&L + BS + CF)
**What this recipe produces.** 4 sheets: `Assumptions`, `P&L`, `Balance Sheet`, `Cash Flow`, plus `Summary`. Year columns 2024A · 2025E · 2026E · 2027E. Balance-check row on BS; cash-reconciliation row on CF. Every statement row = formula → Assumptions.
**Build order (MANDATORY).** `Assumptions → P&L → Balance Sheet → Cash Flow → Summary`. Do NOT build BS before P&L — `RetainedEarnings` depends on `NI`. Do NOT build CF before BS — `CF.OpeningCash = prior period CF.EndingCash` self-chain requires BS cash anchored for Y1. The skill's Gate 4 balance check fails silently if order is wrong.
**Step 1 — sheets + tab colors + freeze panes.**
```bash
officecli add "$FILE" / --type sheet --prop name=Assumptions --prop tabColor=FFC000
officecli add "$FILE" / --type sheet --prop name=P&L --prop tabColor=4472C4
officecli add "$FILE" / --type sheet --prop name='Balance Sheet' --prop tabColor=4472C4
officecli add "$FILE" / --type sheet --prop name='Cash Flow' --prop tabColor=4472C4
officecli add "$FILE" / --type sheet --prop name=Summary --prop tabColor=70AD47
officecli set "$FILE" /Assumptions --prop freeze=B2
officecli set "$FILE" /P&L --prop freeze=B3
officecli set "$FILE" "/Balance Sheet" --prop freeze=B3
officecli set "$FILE" "/Cash Flow" --prop freeze=B3
```
**Step 2 — assumptions (blue, yellow-fill on key drivers).** Year headers row 2, labels down col A, blue numeric inputs on B:E. Drivers: `RevenueGrowth`, `GrossMargin`, `OpExRatio`, `TaxRate`, `DaysReceivable/Inventory/Payable`, `CapExRatio`, `DepreciationYears`. `font.color=0000FF` on B:E. Yellow-fill (`fill=FFFF00`) the 3–5 scenario-switched drivers.
**Declare named ranges for ≥3-use drivers and reference them** (`StartingARR`, `TaxRate`, `OpeningCash`, `GrowthRate`, `GrossMargin`). Formulas: `=StartingARR` not `=Assumptions!B4`; `=EBT*TaxRate` not `=EBT*Assumptions!B8`. Declared-unused names = dead decoration, Gate 6 rejects.
**Step 3 — P&L rows (all formulas).** Rows: `Revenue` / `COGS` / `Gross Profit` / `OpEx` / `EBITDA` / `D&A` / `EBIT` / `Interest` / `EBT` / `Tax` / `Net Income`. Every row = formula referencing `Assumptions` or prior-row cells. Example revenue-side block — **substitute your row numbers**. Row-map for this example: `B3=Revenue, B4=COGS, B5=Gross Profit, B7=OpEx, B9=EBITDA, B10=EBIT, B15=Net Income`. Submit as single non-resident batch:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/P&L/B3","props":{"formula":"Assumptions!B5","font.color":"008000"}},
{"command":"set","path":"/P&L/C3","props":{"formula":"B3*(1+Assumptions!C6)"}},
{"command":"set","path":"/P&L/D3","props":{"formula":"C3*(1+Assumptions!D6)"}},
{"command":"set","path":"/P&L/E3","props":{"formula":"D3*(1+Assumptions!E6)"}},
{"command":"set","path":"/P&L/B4","props":{"formula":"-B3*(1-Assumptions!B7)"}},
{"command":"set","path":"/P&L/B5","props":{"formula":"B3+B4"}}
]
EOF
```
Assumptions refs (`B5`, `C6`, `B7`) are also placeholder rows — better: **define named ranges** for each driver (Step 2) so formulas read `=StartingRevenue*(1+RevenueGrowth_Y2)` regardless of row layout. Repeat for `OpEx` / `D&A` / `Interest` / `Tax` / `NI`. `font.color=008000` on every cross-sheet-reference cell; same-sheet cells default `000000`. `numFmt='$#,##0;($#,##0);"-"'` on all $ rows.
**Step 4 — Balance Sheet rows (all formulas).** Assets = `Cash + AR + Inventory + Net PP&E`. Liab = `AP + Debt`. Equity = `OpeningEquity + RetainedEarnings`. Working-capital rows use Days assumptions: `AR = Revenue × DaysReceivable / 365`. `Net PP&E` rolls forward: `Beg + CapEx − Depreciation`. **`BS.Cash` is NOT an independent plug** — it MUST equal `'Cash Flow'!B<ending-cash-row>` (populated in Step 5).
**Retained Earnings — live formula every period.** `BS.RE(t) = BS.RE(t-1) + 'P&L'!NI(t) − Dividends(t)`. Hardcoded RE rounds to whole dollar → BS shows ±$1 off every period (CFO reads "model doesn't balance"). For Y1 Historical RE (no prior NI), compute via BS identity as a **live formula**: `BS!RE_Y1 = TotalAssets − TotalLiabilities − PaidInCapital`. Blue-font + classic comment on the Y1 cell; Y2..Y5 stay NI-driven.
**Step 5 — Cash Flow rows (all formulas).** Operating: `NI + D&A − ΔWorkingCapital`. Investing: `−CapEx`. Financing: `ΔDebt − Dividends`. Ending Cash = `Opening + Operating + Investing + Financing`. **Year 2+ Opening Cash = prior period Ending Cash** — self-chain on the same sheet: `C17 = B19`, `D17 = C19`, `E17 = D19`. The Y1 `OpeningCash` is an Assumptions input.
**Step 6 — Balance check + cash reconciliation rows (hard delivery checks).** Row-map for this example: `Balance Sheet: B10=Total Assets, B15=Total Liab, B17=Total Equity, B18=Balance Check`; `Cash Flow: B5=BS.Cash (cross-sheet anchor), B19=CF.Ending Cash, B21=CF-BS Cash Recon`. Substitute your layout's rows — the logic is the check, not the cell addresses.
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/Balance Sheet/B18","props":{"formula":"IF(ABS(B10-B15-B17)<0.01,\"OK\",\"IMBALANCED: \"&ROUND(B10-B15-B17,0))","bold":"true","font.color":"000000"}},
{"command":"set","path":"/Cash Flow/B21","props":{"formula":"IF(ABS(B19-'Balance Sheet'!B5)<0.01,\"OK\",\"CF != BS CASH: \"&ROUND(B19-'Balance Sheet'!B5,0))","bold":"true"}}
]
EOF
```
Replicate across columns C/D/E. Apply red fill (`fill=FFC7CE`) conditionally via `type=containsText --prop text=IMBALANCED` or `text="CF !="`. Gate 4 queries these rows and refuses delivery on any `IMBALANCED`.
**Step 7 — cache refresh + format pass.** Re-set every summary cell on `Summary`, every balance-check / recon cell, and every cross-sheet reference on BS / CF (non-resident, single batch per sheet). Apply column widths (`col[A]=28`, `col[B:E]=15`), `numberformat='$#,##0;($#,##0);"-"'` on all dollar rows, header fills (`fill=1F3864`, `font.color=FFFFFF`, `bold=true`) on section-header rows (REVENUE / COGS / ASSETS / LIABILITIES). Header fill must cover A:E, not just the label cell (→ xlsx v2 §visual floor).
**Step 8 — Summary / Dashboard KPIs + charts.** Minimum 4 KPIs: `Revenue 27E`, `EBITDA Margin 27E`, `Ending Cash 27E`, `Net Income CAGR` — each a formula referencing a statement cell, green font.
**Minimum 3 charts on any Dashboard delivered to a board / executive audience** — one chart is a draft, three is a deliverable. Pre-populate `Summary!A10:E13` with Gross Margin / EBITDA Margin / NI Margin ratio rows (formulas referencing `P&L`) before adding the margin chart.
```bash
# (1) Top-line trend (Revenue + EBITDA).
officecli add "$FILE" /Summary --type chart --prop chartType=column --prop dataRange='P&L!A2:E5' --prop title='Revenue & EBITDA' --prop width=14cm --prop height=8cm
# (2) Margin trend (Gross / EBITDA / NI margin).
officecli add "$FILE" /Summary --type chart --prop chartType=line --prop dataRange='Summary!A10:E13' --prop title='Margin trend' --prop width=14cm --prop height=8cm
# (3) Cash trajectory (Ending Cash ± Runway).
officecli add "$FILE" /Summary --type chart --prop chartType=area --prop dataRange='Cash Flow!A19:E19' --prop title='Ending cash' --prop width=14cm --prop height=8cm
```
**Verification (run all three):**
```bash
# Balance check every period must say OK (range get → cells under .data.results[0].children[])
officecli get "$FILE" "/Balance Sheet/B18:E18" --json | jq '.data.results[0].children[] | .format.cachedValue // .text'
# Cash recon every period must say OK
officecli get "$FILE" "/Cash Flow/B21:E21" --json | jq '.data.results[0].children[] | .format.cachedValue // .text'
# Summary KPIs are plausible numbers, not null
officecli get "$FILE" "/Summary/B2:B5" --json | jq '.data.results[0].children[].format.cachedValue'
```
### Recipe B — DCF valuation
**What this recipe produces.** Sheets: `Assumptions`, `FCF` (10-year forecast), `WACC` (panel), `DCF` (NPV + TV + equity bridge), `Sensitivity` (2-axis grid). Output: `Implied Equity Value` + `Implied Per-Share`, with a `WACC × g` sensitivity.
**Build order.** `Assumptions → FCF → WACC → DCF → Sensitivity`.
**Step 1 — named ranges for key drivers.** DCF's readability depends on names. Every formula below uses `WACC`, `TaxRate`, `g` — not `$B$6`:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/","type":"namedrange","props":{"name":"WACC","ref":"WACC!$B$12"}},
{"command":"add","parent":"/","type":"namedrange","props":{"name":"TaxRate","ref":"Assumptions!$B$8"}},
{"command":"add","parent":"/","type":"namedrange","props":{"name":"TerminalGrowth","ref":"Assumptions!$B$15"}},
{"command":"add","parent":"/","type":"namedrange","props":{"name":"NetDebt","ref":"Assumptions!$B$20"}},
{"command":"add","parent":"/","type":"namedrange","props":{"name":"SharesOut","ref":"Assumptions!$B$21"}}
]
EOF
```
**Step 2 — FCF build (10 years).** Columns B:K = Y1..Y10. Rows: `Revenue` (from growth) / `EBIT` (revenue × margin) / `EBIT × (1 − TaxRate)` (NOPAT) / `+ D&A` / `− CapEx` / `− ΔNWC` / `= FCF`. Use Assumptions-driven ratios (`CapEx = Revenue × CapExRatio`). All cells formulas, black font, `numFmt='$#,##0;($#,##0);"-"'`.
**Step 3 — WACC panel.** On `WACC` sheet, an 8-row panel: `Risk-free rate` / `Equity risk premium` / `Beta` / `Cost of equity` (=Rf + β×ERP) / `Pre-tax debt cost` / `After-tax debt cost` (=×(1−TaxRate)) / `Equity weight` / `Debt weight` / `WACC` (=We×Re + Wd×Rd_after_tax). Inputs blue; derived rows black.
**Step 4 — Terminal value + NPV + equity bridge.** Row-map: `DCF: B/C 3=TV, 4=PV explicit FCF, 5=PV terminal, 6=EV, 7=Net Debt, 8=Equity Value, 9=Per-Share`; `FCF: row 2 = periods (1..10), row 11 = FCF, B:K = Y1..Y10`. Substitute your rows. Notes column cells use `{"value":"text"}`, never `{"formula":"..."}` — formula-style prose yields `#NAME?` on open (see callout after Recipe C Step 5). On `DCF` sheet:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/DCF/B3","props":{"value":"Terminal value (Gordon growth)"}},
{"command":"set","path":"/DCF/C3","props":{"formula":"FCF!K11*(1+TerminalGrowth)/(WACC-TerminalGrowth)","font.color":"008000","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B4","props":{"value":"PV of explicit-period FCF (10 yr)"}},
{"command":"set","path":"/DCF/C4","props":{"formula":"SUMPRODUCT(FCF!B11:K11/(1+WACC)^FCF!B2:K2)","font.color":"008000","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B5","props":{"value":"PV of terminal value"}},
{"command":"set","path":"/DCF/C5","props":{"formula":"C3/(1+WACC)^10","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B6","props":{"value":"Enterprise value"}},
{"command":"set","path":"/DCF/C6","props":{"formula":"C4+C5","bold":"true","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B7","props":{"value":"Less: Net debt"}},
{"command":"set","path":"/DCF/C7","props":{"formula":"-NetDebt","font.color":"008000","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B8","props":{"value":"Equity value"}},
{"command":"set","path":"/DCF/C8","props":{"formula":"C6+C7","bold":"true","font.color":"000000","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/DCF/B9","props":{"value":"Implied per-share"}},
{"command":"set","path":"/DCF/C9","props":{"formula":"C8/SharesOut","bold":"true","numberformat":"$0.00"}}
]
EOF
```
**`NPV` vs `SUMPRODUCT` — both cache correctly.** The evaluator computes `NPV(rate, cross_sheet_range)` and caches the right value (verify with a readback). `SUMPRODUCT(values/(1+rate)^periods)` is the algebraic equivalent (period row `FCF!B2:K2 = 1..10` is a one-time setup); it is shown here as an OPTIONAL audit-readability choice — the explicit discounting series is sometimes easier for a reviewer to trace, not a cache workaround. Either form is fine. For irregular dates, `XNPV(rate, values, dates)` likewise caches; the SUMPRODUCT equivalent is `SUMPRODUCT(values/(1+rate)^((dates-base_date)/365))`.
**Step 5 — 2-axis sensitivity grid (WACC × g).** 5×5 grid. Rows = WACC values `7.5% ... 11.5%`, cols = `g` values `1.5% ... 3.5%`. Each cell = one self-contained formula re-running the DCF with the grid's WACC and g substituted. Template:
```bash
# Cell D14 (first data cell, grid anchor at C14 = WACC label, C15 = first WACC value)
# Substitute $D$13 (this cell's g) and $C15 (this cell's WACC) into a replicated EV + equity formula.
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/Sensitivity/D15","props":{"formula":"(NPV($C15,FCF!$B$11:$K$11)+(FCF!$K$11*(1+D$14)/($C15-D$14))/(1+$C15)^10+(-NetDebt))/SharesOut","numberformat":"$0.00"}}
]
EOF
```
Copy the formula across D15:H19 (5×5 grid). Row 14 carries g values (blue input); column C carries WACC values (blue input). Row 13 and column B carry labels. Apply 3-color gradient CF for quick-read (green = upside, red = downside):
```bash
officecli add "$FILE" /Sensitivity --type conditionalformatting \
--prop type=colorScale --prop ref=D15:H19
```
**No Excel Data Tables.** Excel's native `/Data/Table` 2-variable table is not reliably supported via the CLI — each grid cell MUST be an explicit formula. Copy the template, do not try `Data Table` input cells.
**Verification.**
```bash
officecli get "$FILE" "/DCF/C8" --json | jq '.data.results[0].format.cachedValue' # equity value, plausible $
officecli get "$FILE" "/DCF/C9" --json | jq '.data.results[0].format.cachedValue' # per-share, in $XX.XX range
officecli get "$FILE" "/Sensitivity/F17" --json | jq '.data.results[0].format.cachedValue' # grid center cell, plausible
```
If `C8` or `C9` come back `null` or show the `#OCLI_NOTEVAL!` sentinel, re-set them (non-resident) — see §Build-order & cache-drift.
### Recipe C — LBO model
**What this recipe produces.** Sheets: `Assumptions`, `S&U` (Sources & Uses), `Debt` (multi-tranche schedule), `P&L` (5-yr), `CF`, `Exit` / `Returns`. Outputs: `MOIC`, `IRR`, and a 4-tier returns waterfall. LBO is the stress test — expect circular refs (interest ↔ cash), deepest cross-sheet chains, and the heaviest use of named ranges.
**Build order.** `Assumptions → S&U → P&L → Debt → CF → Exit → Returns`. P&L before Debt (debt interest depends on P&L EBIT for coverage checks); Debt before CF (CF uses interest + principal amortization). Enable `calc.iterate` before Step 5.
**Step 1 — Sources & Uses (balance required, every fee line itemized).**
```
Uses = Purchase_EV (EntryEBITDA × EntryMultiple) + Transaction_fees (Purchase_EV × TxnFeePct, typ 1.5–2.5%)
+ Financing_fees ((Senior + Mezz) × FinFeePct, typ 1–3%) + Refinanced_debt
Sources = Senior_TLB + Mezz + Revolver_drawn + Sponsor_equity
```
**Sponsor equity — pick one, never both.** (a) **Stated:** `Sponsor_equity = Assumptions!SponsorEquity`, then scale senior/mezz so Sources = Uses (fees absorbed by debt, not a silent plug). (b) **Solved:** `Sponsor_equity = Uses − Senior − Mezz − Revolver − Refinanced`, label "Sponsor Equity (solved)", no standalone Assumptions ref. Hardcoded `SponsorEquity` PLUS a `=Uses − Senior − Mezz` plug guarantees silent fee absorption — stated $140M vs plug $194.67M = $54.67M unaccounted fees, CFO rejection on sight.
```bash
# Sources = Uses hard check.
officecli set "$FILE" /S&U/B12 --prop formula='IF(ABS(SUM(B4:B7)-SUM(B9:B11))<1,"BALANCED","S&U IMBALANCE: "&ROUND(SUM(B4:B7)-SUM(B9:B11),0))' --prop bold=true
# Stated-vs-plug consistency (Gate 4 addendum; only run if you chose pattern (a)).
STATED=$(officecli get "$FILE" /Assumptions/B12 --json | jq -r '.data.results[0].format.cachedValue // "null"')
PLUGGED=$(officecli get "$FILE" /S&U/B10 --json | jq -r '.data.results[0].format.cachedValue // "null"') # B10 = sponsor-equity row on S&U
DELTA=$(python3 -c "print(abs(float('$STATED') - float('$PLUGGED')))" 2>/dev/null || echo 99999)
python3 -c "import sys; sys.exit(0 if float('$DELTA') <= 1 else 1)" && echo "S&U sponsor OK (stated=$STATED plug=$PLUGGED)" || { echo "REJECT Gate 4 S&U: stated $STATED ≠ plug $PLUGGED (Δ=$DELTA) — fees silently absorbed"; exit 1; }
```
Every non-sponsor line on `S&U` is a blue Assumptions input (target EBITDA, entry multiple, fee %s) or a derived formula. No hardcoded Uses / Sources numbers.
**Step 2 — Debt schedule (multi-tranche).** One row per tranche per year. Columns: `BeginningBalance` / `Mandatory amortization` / `Cash sweep` / `EndingBalance` / `AverageBalance` / `InterestExpense`. Senior TLB: 1% mandatory amortization + all excess cash to sweep. Mezz: 0% amortization, interest-only cash-pay. Row-map for this example (senior TLB tranche, year 2 column C): `C4=Beginning Balance, C5=Mandatory Amort, C6=Ending Balance, C7=Cash Sweep, C8=Average Balance, C9=Interest Expense`. `CF!C20` = free cash available to sweep (year-2 ending cash pre-sweep on CF sheet). Substitute your tranche row block per layout.
```bash
# year 2 senior TLB
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/Debt/C4","props":{"formula":"B6"}},
{"command":"set","path":"/Debt/C5","props":{"formula":"-C4*Assumptions!$B$30","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Debt/C6","props":{"formula":"C4+C5+C7","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Debt/C7","props":{"formula":"-MIN(-CF!C20,C4+C5)"}},
{"command":"set","path":"/Debt/C8","props":{"formula":"(C4+C6)/2","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Debt/C9","props":{"formula":"-C8*Assumptions!$B$31","numberformat":"$#,##0;($#,##0);\"-\""}}
]
EOF
# Add the sweep-rule comment as a classic comment (comment is NOT a cell prop — separate --type comment).
officecli add "$FILE" /Debt --type comment --prop ref=C7 --prop text='cash sweep capped at available cash and remaining tranche balance'
```
**Revolver capacity cap.** If your deal uses a revolver tranche, the revolver balance each period is bounded by the commitment ceiling:
```
Revolver_Balance = MIN(Assumptions!RevolverCapacity, MAX(0, prior_revolver + draw − paydown))
```
Without the `MIN(capacity, ...)` outer, a shortfall quarter silently over-draws the facility.
Adjust row indices to your layout. Repeat for each tranche (senior / mezz / revolver) and each year.
**Step 3 — P&L (5-year) + interest from Debt.** P&L interest row pulls from Debt: `Interest = 'Debt'!TotalInterestRowY<N>`. This creates the **circular reference**: Interest → NI → CF → Cash Sweep → Debt balance → Interest.
**Write-order warning.** `calc.iterate=true` governs _recalculation_, not write-phase. Appending the closing leg of a cross-sheet ring to a file that already contains the ring deadlocks the engine at 100% CPU regardless of `iterate`. For complex rings (multi-tranche LBO, revolver + TLB + mezz), use §Write-order surgery below (de-ring → write downstream → re-ring). Enable `calc.iterate=true` BEFORE writing ring formulas:
```bash
officecli set "$FILE" / --prop calc.iterate=true --prop calc.iterateCount=100 --prop calc.iterateDelta=0.001
```
`iterate` converges via successive approximation for naturally-dampening loops (higher interest → less cash → less sweep → higher balance, bounded by EBIT). `#REF!` or divergent values = pause; fix algebra, do not raise `iterateCount` to 1000.
**Step 4 — CF + cash sweep.** Ending cash = Opening + CFO − CapEx − Mandatory amort − Cash sweep. Cash sweep = `MIN(freeCashAfterCapEx, seniorDebtBalance + seniorMandatoryAmort)`. The `MIN` cap prevents swept-below-zero.
**Step 5 — Exit + Returns.** Row-map: `Exit: B3=Exit EV, B4=Less: remaining debt, B5=Exit equity to sponsor`; `Returns: B3=MOIC, B4=IRR`.
```bash
# Values/formulas — single non-resident batch.
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"set","path":"/Exit/B3","props":{"formula":"'P&L'!F8*Assumptions!$B$25","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Exit/B4","props":{"formula":"-('Debt'!F6+'Debt'!F13)","font.color":"008000","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Exit/B5","props":{"formula":"B3+B4","bold":"true","numberformat":"$#,##0;($#,##0);\"-\""}},
{"command":"set","path":"/Returns/B3","props":{"formula":"'Exit'!B5/('S&U'!B9)","numberformat":"0.00\"x\""}},
{"command":"set","path":"/Returns/B4","props":{"formula":"IRR({-'S&U'!B9,0,0,0,0,'Exit'!B5})","numberformat":"0.0%"}}
]
EOF
# Classic comments — one --type comment per anchor cell.
officecli add "$FILE" /Exit --type comment --prop ref=B3 --prop text='Exit EV = Y5 EBITDA × exit multiple'
officecli add "$FILE" /Returns --type comment --prop ref=B3 --prop text='MOIC = exit equity / sponsor equity'
officecli add "$FILE" /Returns --type comment --prop ref=B4 --prop text='IRR — 5-yr, entry + exit only; use XIRR for mid-year dividends'
```
**Callout — labels: `comment` element vs Notes column vs `formula` (three distinct mechanics).**
- **Hover tooltip** → `officecli add ... --type comment --prop ref=<cell> --prop text='...'`. The **`comment` key is NOT a valid prop on `set cell`** (not in `officecli help xlsx cell`) — it silently drops when embedded inside a `set cell` props dict. Use the dedicated element.
- **Visible text in an adjacent Notes column** → `{"command":"set","path":"/DCF/D3","props":{"value":"TV = FCF × (1+g) / (WACC−g)"}}` — **`value`, not `formula`**, plain quoted string.
- **Formula-style prose written as a real formula** → NEVER. `{"formula":"FCF10*(1+g)/(WACC-g)"}` produces `#NAME?` in Excel (`FCF10`, `g`, `WACC` are unbound identifiers in that cell context).
For mid-year dividends or partial exits, use `XIRR({cashflows}, {dates})` instead of `IRR`.
**Step 6 — Returns waterfall (optional, 4-tier LP/GP).** Tiers: (1) LP preferred return 8% ; (2) GP catch-up to 20% ; (3) 80/20 split above hurdle ; (4) 100% to LP on loss. Each tier is a `MAX(0, MIN(...))` clamp. See §Sensitivity & scenarios for the general grid pattern.
**Verification.**
```bash
officecli get "$FILE" /S&U/B12 --json | jq '.data.results[0].format.cachedValue // .data.results[0].text' # must say BALANCED
officecli get "$FILE" /Returns/B3 --json | jq '.data.results[0].format.cachedValue' # MOIC, expect 2.0x-4.0x typical
officecli get "$FILE" /Returns/B4 --json | jq '.data.results[0].format.cachedValue' # IRR, expect 0.15-0.30 typical
# Iterate converged?
officecli query "$FILE" 'cell:contains("#REF!")' --json | jq '.data.results | length' # must be 0
```
## Sensitivity & scenarios
**Three patterns, pick one:**
- **(a) Base / Upside / Downside columns** on Assumptions — side-by-side scenarios, dropdown-less switch via an "Active" column + `INDEX/MATCH`.
- **(b) Dropdown + `INDEX/MATCH` switch** — one validation dropdown on Summary drives every driver via `INDEX(Base:Downside, MATCH(Dropdown, ScenLabels, 0))`.
- **(c) 2-axis sensitivity grid** — 5×5 or 7×7, one self-contained formula per cell, row/col headers are the two drivers. See Recipe B Step 5 for WACC × g.
Mixing (a)+(b) creates circular input (scenario picked by dropdown AND overwritten by Active column) — pick one.
**Grid rule:** each cell substitutes row-driver and col-driver into a self-contained copy of the output formula. Cannot reference the `WACC` named range (that's the panel) — reference the grid's axis cell.
**Dropdown scenario switch.** One `validation` dropdown on Summary drives every `Assumptions` row:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/Summary","type":"validation","props":{"sqref":"B1","type":"list","formula1":"Base,Upside,Downside"}},
{"command":"set","path":"/Assumptions/B5","props":{"formula":"INDEX(C5:E5,MATCH(Summary!$B$1,$C$4:$E$4,0))"}}
]
EOF
# If you want a hover tooltip on B5, add it separately:
officecli add "$FILE" /Assumptions --type comment --prop ref=B5 --prop text='Revenue growth — picked by Summary!B1 scenario dropdown'
```
Every `Assumptions` driver row gets the same `INDEX/MATCH`. Base / Upside / Downside columns on C:E stay blue (hardcoded scenario inputs).
**Football-field chart pattern (DCF valuation summary).** Horizontal Low→High bars for 3–5 valuation methods (DCF base, DCF bear, Trading comps, Precedent txns, LBO floor) stacked vertically. On a `Football` sheet: col A = method label, col B = Low $, col C = High $, col D = `=C−B` (width). Chart as a stacked bar with column B as an invisible first series (white/no-fill) and column D as the visible series — `dataRange=Football!A3:D7`, `chartType=bar`. Excel reads this as a floating bar per method.
## Financial function patterns
Terse reference — not a finance textbook. If you don't know what these do, pause and ask the user.
| Function | Prefer over | Why |
|---|---|---|
| `XNPV(rate, values, dates)` | `NPV` | Irregular cash flow dates (M&A close mid-year, staggered tranches) |
| `XIRR(values, dates)` | `IRR` | Irregular dates; multiple sign changes handled better |
| `INDEX(range, MATCH(lookup, key, 0))` | `VLOOKUP` | Insert-safe (VLOOKUP breaks when a column is inserted in the source range) |
| `IFERROR(x/y, 0)` or `IF(y=0, 0, x/y)` | bare division | Guard every `/` in a financial model — `#DIV/0!` shipped = delivery failure |
| `MIRR(values, financeRate, reinvestRate)` | `IRR` with sign flips | When cash-flow pattern has 2+ sign changes |
| `SUMIFS(sumRange, criteriaRange1, criterion1, ...)` | `SUMPRODUCT((...))` array | Clearer intent for conditional sums; either evaluates correctly |
**Evaluator coverage — verify, don't preemptively hardcode.** The CLI evaluator computes the finance functions this skill uses — `NPV` / `XNPV` / `IRR` / `XIRR`, array-literal formulas (`IRR({...})`), and `SUMPRODUCT(1/COUNTIF(range,range))` distinct-count — and caches the correct value with `evaluated:true`. There is no `NPV→0` rewrite obligation and no distinct-count `1/N` trap. The honest rule: build the formula you mean, then verify the cached value with a readback (`get ... --json | jq '.data.results[0].format.cachedValue'`). Only if a specific cell genuinely comes back with the `#OCLI_NOTEVAL!` sentinel (formula written before its inputs, or an unsupported function) should you fall back — first re-set non-resident after `close`; if it still won't evaluate, hardcode the computed value with a blue font and a classic comment via `officecli add "$FILE" /Sheet --type comment --prop ref=<cell> --prop text='cached valuation; refreshes on open in Excel — do not edit'`, and disclose in delivery notes.
## Circular references & iterative calc
**Enable `calc.iterate` ONLY when circularity is algebraically justified:** Interest ↔ Cash (LBO revolver / cash sweep), Tax shield ↔ NI (rare — most 3-statement models compute interest before tax and avoid), Revolver plug ↔ Ending cash (corporate cash waterfall with min-cash).
```bash
officecli set "$FILE" / --prop calc.iterate=true --prop calc.iterateCount=100 --prop calc.iterateDelta=0.001
```
`iterateCount=100` / `iterateDelta=0.001` are Excel defaults, fine for naturally dampening loops.
### Write-order surgery (de-ring → write downstream → re-ring)
`calc.iterate` controls recalc, not write-phase. Appending the closing leg of an already-wired cross-sheet ring (Debt.Interest ↔ CF.Cash ↔ Debt.CashSweep) deadlocks at 100% CPU; `view html` / `get` also hang on a non-converged ring.
**3-step playbook:**
1. **De-ring** — write Debt with the 10–20 ring cells set to literal `0` (e.g. `C7=0`, not `=-MIN(...)`). Removes the ring.
2. **Write downstream** — build all non-circular chains (P&L, CF, Exit, Returns, Summary, grid) non-resident, one heredoc per sheet. Everything caches against the zeroed cells.
3. **Re-ring** — close all residents, re-set each circular cell with its real formula, one `set` per cell, non-resident.
**Acceptance.** `get /Debt/C7 --json | jq '.data.results[0].format.cachedValue'` returns non-zero non-null. If a cell still deadlocks, leave `=0` + classic comment `"circular; recalculates in Excel on F9"`, flag at delivery. Never paper over with `iterateCount=1000`.
**Do NOT use `iterate` as a band-aid for `#REF!` / divergent values.** Raising `iterateCount` to 1000 hides the bug and ships a plausibly-wrong value; `validate` does not catch it. Break the loop algebraically (e.g. interest on opening balance only, not average).
**Verify convergence.** Read the loop cell, bump a driving assumption and back, re-read — values must match:
```bash
V1=$(officecli get "$FILE" /Debt/C9 --json | jq '.data.results[0].format.cachedValue')
officecli set "$FILE" /Assumptions/B31 --prop value=0.085
officecli set "$FILE" /Assumptions/B31 --prop value=0.0845
V2=$(officecli get "$FILE" /Debt/C9 --json | jq '.data.results[0].format.cachedValue')
[ "$V1" = "$V2" ] && echo "Iterate converged" || echo "WARN: drift V1=$V1 V2=$V2 — tighten iterateDelta or check algebra"
```
## Audit & Delivery Gate
**Assume there are problems.** First build is almost never correct. Run every gate below; every check must print its success line. `validate` passing is not delivery — the model can pass schema and still be wrong by a factor of 10.
### Gates 1–3 — inherited from xlsx v2 verbatim
→ see xlsx v2 §QA minimum cycle (Gates 1–3 cover `view issues`, error-cell query, `validate` after close). Run them first, exactly as written in xlsx v2. No financial-model-specific tweaks.
### Gate 4 — statement integrity (3-statement & LBO)
Balance-check and cash-reconciliation rows produced by Recipe A / C must show `OK` / `BALANCED` every period. `query` the check rows and refuse on any `IMBALANCED` / `CF !=`:
```bash
BS_FAIL=$(officecli query "$FILE" 'cell:contains("IMBALANCED")' --json | jq '.data.results | length')
CF_FAIL=$(officecli query "$FILE" 'cell:contains("CF !=")' --json | jq '.data.results | length')
SU_FAIL=$(officecli query "$FILE" 'cell:contains("S&U IMBALANCE")' --json | jq '.data.results | length')
if [ "$BS_FAIL" -eq 0 ] && [ "$CF_FAIL" -eq 0 ] && [ "$SU_FAIL" -eq 0 ]; then
echo "Gate 4 OK (balance + recon + S&U all pass)"
else
echo "REJECT Gate 4: BS=$BS_FAIL CF=$CF_FAIL S&U=$SU_FAIL"; exit 1
fi
```
If any fail, the model is silently wrong — fix the upstream chain before delivery. Most common cause: a cross-sheet formula stored `\!` (shell-mangled) — run `officecli query "$FILE" 'cell:contains("\\\\!")'` and re-enter via batch heredoc.
### Gate 5 — cached-value sanity on valuation cells
NPV / IRR / XIRR / equity-bridge / MOIC / summary KPI cells that ship unevaluated (`#OCLI_NOTEVAL!` sentinel, typically because the formula was written before its inputs) send a blank/wrong number to a reader who does not recalc on open. List every valuation cell and confirm a cached value is present:
```bash
# Customize the path list per recipe — this is the DCF example
for P in "/DCF/C4" "/DCF/C5" "/DCF/C6" "/DCF/C8" "/DCF/C9"; do
V=$(officecli get "$FILE" "$P" --json | jq -r '.data.results[0].format.cachedValue // "null"')
if [ "$V" = "null" ] || [ "${V#\#OCLI_NOTEVAL}" != "$V" ]; then
echo "REJECT Gate 5: $P unevaluated (cached=$V) — re-set after close (see §Build-order & cache-drift)"; exit 1
fi
echo "Gate 5 $P: cached=$V OK"
done
```
For LBO, extend the list: `/Exit/B5`, `/Returns/B3`, `/Returns/B4`. For 3-statement, extend with `/Summary/B2:B5`.
### Gate 6 — hardcode / zone discipline
Every Calc sheet has zero numeric hardcodes. Executable:
```bash
# `cell:not(:has(formula))` selects the literal cells (and `cell:has(formula)` the formula cells).
HARDCODE=$(officecli query "$FILE" 'cell[type=Number]' --json \
| jq '[.data.results[] | select(.format.formula == null) | select(.path | test("/(P&L|Balance Sheet|Cash Flow|DCF|Debt|FCF|WACC|Exit|Returns)/"))] | length')
[ "$HARDCODE" -eq 0 ] && echo "Gate 6 OK (no hardcodes on Calc sheets)" || { echo "REJECT Gate 6: $HARDCODE hardcoded numeric cells on Calc zone — move to Assumptions"; exit 1; }
# Named-range coverage + dead-decoration audit: ≥3 ranges declared AND each referenced by ≥1 formula.
NR=$(officecli query "$FILE" namedrange --json | jq '.data.results | length')
[ "$NR" -ge 3 ] && echo "Gate 6 OK ($NR named ranges)" || echo "WARN Gate 6: only $NR named ranges"
DEAD=0
for NR_NAME in $(officecli query "$FILE" namedrange --json | jq -r '.data.results[].format.name'); do
# Match the formula SOURCE (`formula~=`), not the cached/displayed RESULT — `:contains` would scan
# the computed value and report every name as dead (false reject).
USES=$(officecli query "$FILE" "cell[formula~=$NR_NAME]" --json | jq '.data.results | length')
[ "$USES" -ge 1 ] && echo " $NR_NAME: $USES uses OK" || { echo " WARN: $NR_NAME unused"; DEAD=$((DEAD+1)); }
done
[ "$DEAD" -eq 0 ] && echo "Gate 6 named-range audit OK" || { echo "REJECT Gate 6: $DEAD dead-decoration name(s)"; exit 1; }
```
### Gate 5b — visual audit via HTML preview (mandatory)
Gates 1–4/6 are grep defenses — they cannot see a rendered sheet. Run `officecli view "$FILE" html` and Read the returned HTML. Walk every sheet (inherits xlsx v2 visual floor):
- No `###` in any numeric cell (widen column).
- No truncated labels / section headers (widen column or `alignment.wrapText=true`).
- No placeholder tokens (`TBD`, `{var}`, `xxxx`) — Gate 6.1 grep below.
- Balance-check / recon rows say `OK` / `BALANCED` every period column.
- Dashboard charts render, y-axis = 0 on ARR/revenue lines, source data matches statement sheet.
- Sensitivity grid colors read green (upside) → red (downside) — color-scale CF applied.
- No stale cached `0` on summary KPIs; if present, run cache-refresh pass.
REJECT on any defect. **Human preview:** `officecli watch "$FILE"`, or open in Excel / WPS / Numbers — final colors + chart fidelity only fully render in the target viewer.
### Gate 6.1 — token / placeholder sweep
```bash
LEAK=$(officecli view "$FILE" text | grep -niE 'TBD|\(fill in\)|xxxx|lorem|\{\{|placeholder|coming soon')
[ -z "$LEAK" ] && echo "Gate 6.1 OK (no placeholder tokens)" || { echo "REJECT Gate 6.1:"; echo "$LEAK"; exit 1; }
```
### Honest limit
`validate` catches schema errors, not finance errors. A model passes `validate` with `BS.Cash` hardcoded to force balance, an `NPV` cached at `0`, a sensitivity grid all-zero because it was built before FCF, a `#NAME?` runtime on a `P&L`-named sheet with unquoted refs. Gates 4 / 5 / 6 / 5b exist because schema-level `validate` cannot catch any of this.
## Known Issues & Pitfalls
→ Base pitfalls (cross-sheet `!` trap, batch JSON dotted-name rule, renderer caveats): see xlsx v2 §Known Issues & Pitfalls — all apply.
Financial-model-specific:
- **AP sign on COGS.** Accounts Payable: if COGS is stored negative on the P&L, AP formula must negate — `=-COGS*DaysPayable/365`. Wrong sign inflates NWC and flips CF direction. Silent; passes `validate`.
- **`#NAME?` not caught by `query` / `validate`.** A cross-sheet formula referencing `P&L!B3` without quoting the sheet name (because `&` is special) lands at runtime as `#NAME?`. Always write cross-sheet refs as `'P&L'!B3` — single-quote the sheet name if it contains `&`, space, `(`, `)`, etc. Gate 5b visual check is the only detection.
- **Iterative calc silent non-convergence.** `calc.iterate=true iterateCount=100` converges at whatever the cap lands on — even if the true answer is 2× that. Always run convergence verify (§Circular references). Complex LBO rings (multi-tranche debt + sweep + tax shield) may not converge; when `cachedValue=0` on a ring cell, use §Write-order surgery.
- **Batch-while-resident deadlock on circular writes.** Writing the closing leg of a cross-sheet ring via `batch` with a resident open deadlocks at 100% CPU. Even single `set` on a ring cell can hang. Fix: close residents, write the ring in two passes per §Write-order surgery. Non-resident single-heredoc is the only safe form.
- **Cross-sheet cached value stale in `view html`.** Downstream written in the same sequence as upstream caches `0`. Excel resolves on open; HTML preview does NOT. Re-set every downstream non-resident after the chain (§Build-order & cache-drift).
- **`NPV()` / `XNPV()` evaluate and cache correctly.** The evaluator computes both (same-sheet and cross-sheet); no rewrite is required. `SUMPRODUCT(values/(1+rate)^periods)` remains an optional audit-readability alternative, not a cache workaround.
- **Sensitivity-grid build order still matters.** A grid cell written before its FCF/WACC inputs exist evaluates against empty inputs and may cache a wrong/blank value. Build FCF + WACC + DCF first, then the grid in a separate non-resident batch, and verify (`jq '.data.results[0].format.cachedValue'`). This is an ordering discipline, not an evaluator limitation.
- **`BS.Cash` = CF ending cash always** (including Y1: `BS.Cash = 'Cash Flow'!B19`). Never an independent plug or Assumptions ref — a plugged `BS.Cash` hides balance errors.
- **Year 2+ `Opening Cash` = prior period `Ending Cash`** (`C17=B19`, `D17=C19`). Independent Y2+ opening-cash inputs silently drift from BS.
- **Waterfall chart "total" bars.** `chartType=waterfall` cannot mark total programmatically — use `colors=` convention (dark = total, medium = positive, red = negative). See `help xlsx chart`.
- **DCF per-share when `SharesOut` is a formula.** `=BasicShares + OptionPool × ExerciseAssumption` → add a blue-font assumption cell and point the `SharesOut` named range at the computed cell, not the raw input.
## Help pointer
When in doubt: `officecli help xlsx [element] [--json]`. Help is the authoritative schema; this skill is the decision guide for financial-modeling deltas.
+810
View File
@@ -0,0 +1,810 @@
---
name: officecli-pitch-deck
description: "Use this skill when the user is building a fundraising / investor pitch deck — seed, Series A / B / C, convertible note, SAFE round, strategic raise. Trigger on: 'pitch deck', 'investor deck', 'Series A deck', 'Series B deck', 'Series C deck', 'fundraising deck', 'seed pitch', 'VC deck', 'raising capital', 'term sheet presentation'. Output is a single .pptx. This skill is a scene layer on top of officecli-pptx — inherits every pptx v2 rule (visual floor, grid, palettes, connector canon, Delivery Gate). DO NOT invoke for a generic board review, sales deck, all-hands, or product launch — route those to officecli-pptx base."
---
# OfficeCLI Pitch Deck Skill
**This skill is a scene layer on top of `officecli-pptx`.** Every pptx hard rule — visual delivery floor (title ≥ 36pt / body ≥ 18pt / title ≥ 2× body), 12-column grid on 33.87×19.05cm, 4 canonical palettes, chart-choice decision table, connector canon (`shape` / `from` / `to` / `tailEnd=triangle`), shell escape, resident + batch, Delivery Gate 1–5a — is inherited, not re-taught. This file adds only what **fundraising** needs on top: stage diagnosis (A / B / C), 5 赛道 arc templates, 10 key-slide recipes (cover / problem / solution / market / product / model / traction / team / financials / ask), pitch-specific numbers convention, a VC ship-check, and a pitch-specific fresh-eyes Gate 6.
When the pptx base rules cover it, the text here says `→ see pptx v2 §X`. Read `skills/officecli-pptx/SKILL.md` first if you have not.
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**This skill teaches what a fundraising deck requires, not every command flag.** When a prop name, enum value, or preset is uncertain, consult help BEFORE guessing.
```bash
officecli help pptx # All pptx elements
officecli help pptx <element> # Full schema (e.g. chart, shape, connector, picture)
officecli help pptx <element> --json # Machine-readable
```
Help reflects the installed CLI version. When this skill and help disagree, **help wins.** Every `--prop X=` in this file has been grep-verified against `officecli help pptx <element>` — if help adds / renames a prop in a later version, trust help.
## Mental Model & Inheritance
**Inherits pptx v2.** You should have read `skills/officecli-pptx/SKILL.md` first. This skill assumes you know how to: add slides + shapes + charts + connectors; address by `@name=` / `@id=`; quote paths; use `batch` heredocs; write `--prop tailEnd=triangle` on every flow connector; and run the 5-gate Delivery Gate. If any of those are unfamiliar, open a pptx v2 session before continuing.
## Shell & Execution Discipline
**Shell quoting, incremental execution, `$FILE` convention** → see pptx v2 §Shell & Execution Discipline. Same rules verbatim — quote `[N]` paths, single-quote values containing `$` (including `$35M`, `$1.2B TAM` in a cover or ask slide), never hand-write `\$ \t \n` in executable examples, one command at a time. Examples below use `$FILE` (`FILE="deck.pptx"`).
**Single-quote every shape text containing `$`.** `--prop text="Series B · $35M"` (double quotes) is WRONG — zsh expands `$35M` → empty, deck renders `Series B · M` silently. `--prop text='Series B · $35M'` (single quotes) is right. This is the #1 pitch-deck shell-escape failure mode (`$35M`, `$18M ARR`, `$1.2B TAM` appear on cover/ask/financials/milestones). Gate 2 cannot detect a stripped `$35M` — no residue. Gate 2b catches common strip patterns; single-quoting PREVENTS them.
## What "pitch deck" means here (identity)
A pitch deck is a pptx with a **fundraising layer** on top: VC-oriented narrative arc, verifiable metrics, stage-appropriate data density, founder-credibility surface. Slides are consumed at ~3 seconds per slide in a live room — the pptx v2 rule. Pitch decks add a second constraint on top: **every slide carries one investable proposition**. If a slide is "interesting background" that doesn't move the ask forward, cut it. VCs will not. The base pptx rules still apply; pitch decks add six deltas:
1. **Stage determines everything.** Series A / B / C each dictates slide count, narrative weight, which metrics are must-haves, and tolerance for unit-econ sophistication. A Series A deck with 6 pages of CAC/LTV math reads as over-packaged; a Series B deck missing unit econ reads as incomplete. Pick the stage first — everything downstream follows.
2. **Narrative arc beats feature dump.** 10 essential slides in a fixed order: cover → problem → solution → market → product → model → traction → team → financials → ask. Out of order = VCs disengage.
3. **Numbers are a contract.** TAM/SAM/SOM must be clean three-layer; CAC/LTV must have a payback line; ARR ≠ revenue; Use-of-Funds must be a four-bucket pie. Sloppy numbers = round dies.
4. **Team slide carries prior companies.** Avatar grid alone reads as a student project. Add prior-company logos / names + one-line role. Without this, first-time founders look exactly like first-time founders.
5. **Traction chart y-axis starts at 0.** A "hockey stick" starting at `y_min = 80% of current` is a visual lie — VCs who have seen 10,000 decks spot it in < 2 seconds.
6. **The ask is a slide, not a footnote.** `$XX M` hero + four-bucket Use-of-Funds + runway length. "We're raising some money" is not an ask.
### Reverse handoff — when to go BACK to pptx base
Stay in **pptx v2 base** for board reviews, all-hands, sales decks, product launches, training decks — anything not tied to raising capital. Use **this skill** only when: (a) the user mentions a specific round (seed / Series A / B / C) or a VC meeting, AND (b) the deck needs at least 4 of {problem, traction, team with credentials, Use-of-Funds, stage-appropriate unit econ, financial projections}.
If the user says "fundraising deck" but the context is a corporate BU quarterly ask, that is a board review. Route to pptx v2 Recipe (d) 10-slide blueprint. If the user says "board review" but the context is a small company raising a bridge round, route here.
## Series A / B / C stage diagnosis (decision tool)
**Read this before writing a single command.** Pick the row that matches the user's description — everything downstream (slide count, which metrics, which recipes, what the team slide must show) derives from this one call.
| Stage | Revenue band | Team | Slide count | Dominant narrative (weight) | Must-have data | Common red flag |
|---|---|---|---|---|---|---|
| **Seed** | $0 – $1M ARR (often pre-rev) | 2 – 8 FTE | 10 – 12 | Problem (30%) + Solution (25%) + Team (15%) + Market (15%) + Traction (15%) | Founder-market fit story; 1 – 2 design-partner / pilot logos; top-down TAM ok | Over-claiming traction (10 customers = "market proven") |
| **Series A** | $1 – $5M ARR | 10 – 25 FTE | 12 – 16 | Problem (20%) + Solution (20%) + **Market "why now"** (15%) + Product (15%) + Traction (20%) + Team (10%) | PMF proof (NRR > 110%, low churn), bottom-up TAM/SAM, pipeline / pilots converted | Bottom-up TAM feels fabricated; CAC not yet meaningful but shown anyway |
| **Series B** | $5 – $30M ARR | 30 – 100 FTE | 18 – 22 | **Traction + Unit econ (30%)** + Market + Product + Team + Financials (ask) | ARR curve starting at 0; NRR, CAC, LTV, payback (< 18 mo ideal); cohort retention; logo wall | No unit-econ slide; CAC payback > 24mo without explanation; Use-of-Funds missing % |
| **Series C** | $30M+ ARR | 100+ FTE | 20 – 24 | **Financials + Scale + Moat (40%)** + Market expansion + Team depth | Multi-year GAAP, rule-of-40, GM trajectory, international expansion plan, defensibility | No moat slide; revenue growth without margin story; team slide has no prior CEO / CFO |
| **Bridge / SAFE** | any | any | 8 – 10 | **Specific bridge reason** + runway math + commitments | Prior round context; specific milestone the bridge funds; committed investor amount | Treating a bridge like a Series A — too many slides dilutes the ask |
**Decision procedure.** From one or two user sentences ("Series B, $18M ARR, 120 customers, $35M raise"), pick exactly one stage row. All later choices in this skill reference your stage: which 赛道 template to pull, which recipes are mandatory vs optional, and which Delivery Gate 6 checks fire.
**Corner cases.** Bridge rounds & convertibles between A → B are closer to A or B depending on whether the bridge milestone is "finish PMF" (A shape) or "hit unit-econ target" (B shape). "Extension" rounds at the same stage reuse the earlier stage's skeleton and add a one-slide "progress since last round" update.
**Non-SaaS stage overrides.** The ARR / unit-econ shape of Series B fits SaaS. For other verticals, substitute revenue band + unit-econ equivalent + Gate 6.3 grep:
| Vertical | Revenue "band" at Series B | "Unit econ" equivalent | Gate 6.3 substitute |
|---|---|---|---|
| **Bio / Clinical-stage** | pre-rev, 20–60 FTE | burn rate + runway to next milestone (IND / Ph1 readout / BLA) | `shape:contains("ORR")` OR `contains("Pipeline")` OR `contains("BLA")` OR `contains("runway")` ≥ 1 |
| **Deep Tech / Frontier** | pre-rev or early pilot rev | technical milestones + TRL level + benchmark vs SoTA | `shape:contains("TRL")` OR `contains("benchmark")` ≥ 1 |
| **Marketplace / Network** | GMV $10–100M | take rate + cohort retention + liquidity | `shape:contains("GMV")` + `contains("take rate")` ≥ 1 |
| **Consumer hardware** | $2–15M revenue (shipped units) | contribution margin + repeat rate + blended CAC | `shape:contains("repeat")` OR `contains("contribution")` ≥ 1 |
Substitute the analogue grep when running Gate 6.3 on these verticals. False WARN on SaaS CAC/LTV = expected; real concern = vertical-specific analogue present. Bio Series B decks especially: burn + runway-to-milestone IS the "unit econ" story.
## 赛道 arc templates (5 families)
5 mainstream verticals. Each one has different slide weights because what VCs require as proof-of-concept differs. Pick the vertical row; the slide skeleton is a copy-able starting point. Slide counts assume the matching stage row above.
### (1) B2B SaaS / Enterprise software
Canonical arc — the template most of VC muscle memory is built on. Series B example (20 slides): cover · TL;DR · problem · problem evidence · solution · product loop · market TAM/SAM/SOM · **unit economics (CAC / LTV / payback / GM)** · ARR trajectory · retention cohort · logo wall · team · competitors · financials 4-year · ask. Must-have: unit-econ slide from Series A onward; logo wall from Series B onward.
### (2) Consumer (B2C app / consumer hardware / D2C)
Narrative-driven. Early-stage decks lean on **product-experience screenshots + founding story + "why now"** market timing; lighter on unit econ (which are usually weaker than SaaS). Series A example (14 slides): cover · hook (30-second product demo or 1-line vision) · problem (lived experience) · solution (product shots) · product-experience flow · "why now" market window · pre-order / crowdfunding / early-sales evidence · retention / engagement (DAU, D30) · market (top-down ok if bottom-up unreliable) · competitive positioning · founder story + team · press / endorsements · financials · ask. Must-have: product visuals on ≥ 3 slides; "why now" slide (window justification); engagement metric not just revenue.
### (3) Deep Tech / Frontier tech (AI foundation models, quantum, climate hardware, robotics)
Technology credibility is the sell. Pre-revenue deep tech replaces "traction" with **technical milestones + defensibility**. Series B example (22 slides): cover · thesis (one-line "what changes if this works") · problem (current state of art) · solution (technical approach) · **technology architecture** · benchmarks vs SoTA · pipeline / TRL levels · market (long-tail) · business model · early commercial traction (pilots, LOIs) · IP / patents · team (usually PhD / ex-FAANG-research) · partners · financials · ask. Must-have: benchmark slide; IP slide; team slide dense with PhDs / prior-lab names.
### (4) Marketplace / Network business (two-sided platform, social, commerce)
Liquidity is the metric. Replace "unit econ" with **GMV + take rate + cohort retention + supply / demand balance**. Series A example (15 slides): cover · problem (friction in current supply-demand) · solution · product demo (both sides) · network effects diagram · early liquidity (first-week GMV, time-to-match) · cohort retention · geographic / category expansion plan · competitive positioning vs incumbents · take-rate model · team · financials · ask. Must-have: liquidity metric slide; cohort retention chart; network-effect diagram.
### (5) Bio / Life sciences / Healthtech
Regulatory pipeline IS the business. Replace "product roadmap" with **clinical pipeline + regulatory path + scientific evidence**. Series B example (22 slides): cover · unmet medical need · scientific rationale (mechanism of action) · preclinical / clinical data (ORR, safety, endpoints) · **pipeline chart** (candidates × stages × dates) · differentiation vs standard of care · IP / exclusivity · regulatory strategy (IND, BTD, fast-track) · market (prevalence × pricing) · commercial strategy (orphan / specialty / biosimilar) · partnerships / collaborations · team (CSO / CMO with prior FDA wins) · financials (burn to next milestone) · ask. Must-have: pipeline chart; clinical data slide; team slide with prior regulatory wins.
**Cross-vertical rule.** You can mix elements across templates, but never drop a must-have from your primary vertical. A SaaS deck missing unit econ, a bio deck missing a pipeline chart, a marketplace deck missing a liquidity metric — each is an instant VC disqualification.
## Slide Patterns (layout canon)
Patterns are **layout geometry**; recipes below are **narrative intent**. A slide picks one pattern for its visual shape (6 canonical ones below) and one recipe for what it argues (cover / problem / traction / ...). Multiple recipes can share one pattern — Problem / Why-Now / Traction-callout all lean on the 3-stat row (C.2). Pick the pattern first, then fill it with recipe content.
**Speaker notes rule.** Every content slide (non-cover, non-closing) MUST carry speaker notes via `officecli add "$FILE" /slide[N] --type notes --prop text='…'`. Missing notes = not shippable — inherits pptx v2 §Hard rules (H7). Run `officecli help pptx notes` to confirm prop names before building.
**Pattern reuse discipline.** Never run the same pattern on two consecutive slides — even with different data, two identical geometries in a row read as a template loop. Alternate C.2 with C.4 or C.5b to break rhythm.
**Vertical centering.** When a slide carries fewer elements than the pattern's maximum, nudge y-positions down 2–3cm to center the visual weight. Tables below assume full content.
### C.1 Title / Cover (dark gradient)
3–4 text shapes on a gradient fill. Slide 1 in every deck.
```
+----------------------------------+
| |
| TITLE (centered) |
| tagline |
| |
| round · amount · date |
| ________________________ | <- thin brand band
+----------------------------------+
```
| Element | X | Y | Width | Height | Font / size |
|---|---|---|---|---|---|
| Title | 2cm | 5cm | 29.87cm | 4cm | serif bold, ≥ 36pt (44 typical) |
| Tagline | 2cm | 10cm | 29.87cm | 2cm | sans 18–22pt |
| Meta (round · $ · date) | 2cm | 13cm | 29.87cm | 1.5cm | sans 12–16pt |
**Use this when** the slide is the first one (Cover recipe 1) — 3-second identity grab. Background is a 180° linear gradient between two dark palette shades (e.g. Professional Navy `1E2761 → 0D1F35`). If the title wraps to 2 lines, **add height (4cm → 5cm), never drop font below 36pt** — sub-36pt on a pitch cover reads as timid regardless of content. Transition: fade.
### C.2 3-Stat callout row
Title + 3 big-number / label pairs across. The default for Problem / Why-Now / Traction-callout slides.
```
+----------------------------------+
| Title |
| |
| 73% 12hr $4.2B |
| label label label |
| source source source |
+----------------------------------+
```
| Element | X | Y | Width | Height | Font / size |
|---|---|---|---|---|---|
| Title | 1.5cm | 1cm | 30.87cm | 3cm | serif bold ≥ 36pt |
| Stat 1 number | 2cm | 5cm | 9cm | 4cm | serif bold 60–64pt |
| Stat 1 label | 2cm | 9.5cm | 9cm | 2cm | sans ≥ 16pt (H4 floor) |
| Stat 2 number / label | 12.5cm | (same) | 9cm | (same) | (same) |
| Stat 3 number / label | 23cm | (same) | 9cm | (same) | (same) |
**Use this when** you have 2–3 anchoring numbers and the story is "three facts argue the point" — Problem, Why-Now, Market-callout, single-row Traction. Labels ≥ 16pt is the H4 floor (sub-label exception); a number without a label reads as bravado, so never drop labels to 12–14pt to fit more text.
### C.3 4-Stat callout row
Same geometry as C.2 but 4 columns. Numbers 60pt, width 7cm each.
```
+-------------------------------------+
| Title |
| |
| 73% 12hr $9M 4.2x |
| lbl lbl lbl lbl |
+-------------------------------------+
```
| Element | X positions | Y | Width | Height | Font / size |
|---|---|---|---|---|---|
| Title | 1.5cm | 1cm | 30.87cm | 3cm | serif bold 36pt |
| Stat numbers | 1.5 / 9.5 / 17.5 / 25.5cm | 5cm | 7cm | 4cm | serif bold 60pt |
| Stat labels | (same X) | 9.5cm | 7cm | 2cm | sans ≥ 16pt |
**Use this when** exactly 4 parallel metrics tell the story and 3 feels under-counted. Prefer C.2 if in doubt — 4 always feels tighter than 3, and wrap risk is real.
> **Wrap warning.** At 60pt in 7cm width, dollar patterns with both `$` and `.` fail: `$9.4M` is 5 glyphs but the wide `$` and `.` in a serif bold make it wrap to 2 lines and destroy the callout. Safe dollar shapes at 60pt/7cm: `$9M`, `$96B`, `$4K` (3–4 chars). Non-dollar shapes: `340%`, `4.2x`, `12.3` safe up to 5 chars. Values ≥ 6 chars (`197min`, `3 Days`) will wrap — either (a) drop font to 44–48pt, (b) abbreviate (`197m`, `$9M`), or (c) shift to C.2 (9cm per stat). Single tokens only, no internal spaces.
### C.4 Chart + Context (chart left, stats right)
Chart takes left 55%, 2–3 stacked callouts on the right. The default for Traction / Financials / Market-sizing-with-context.
```
+-------------------------------------+
| Title |
| |
| +---------------+ +--------+ |
| | | | Stat 1 | |
| | chart | +--------+ |
| | | | Stat 2 | |
| +---------------+ +--------+ |
+-------------------------------------+
```
| Element | X | Y | Width | Height |
|---|---|---|---|---|
| Title | 2cm | 1cm | 29.87cm | 3cm |
| Chart | 2cm | 4cm | 17cm | 13cm |
| Stats column | 21cm | 4cm+ | 11cm | 2.5cm number + 1.5cm label (~3.7cm per pair) |
Sub-labels ≥ 16pt (H4 floor). For 5 stats stacked, drop number size to 44pt; 6+ stats means pick a different pattern. Post-batch for column/bar charts: `officecli set "$FILE" "/slide[N]/chart[1]" --prop gap=80` to tighten bar spacing.
**Use this when** one primary chart drives the story and 2–3 numeric anchors reinforce it — Traction (ARR curve + current ARR + YoY + NRR), Financials (4-year column chart + assumption callouts), Market (bar chart + SOM / CAGR / methodology).
### C.5 Icon-in-circle grid (3-row vertical)
3 vertical rows, each = circle icon on the left + title + 1-line description.
```
+---------------------------------------+
| Title |
| |
| (o) Label one |
| description one |
| |
| (o) Label two |
| description two |
| |
| (o) Label three |
| description three |
+---------------------------------------+
```
| Element | X | Y positions | Width | Height | Font / size |
|---|---|---|---|---|---|
| Icon circle | 2cm | 4.5 / 8.5 / 12.5cm | 2.5cm | 2.5cm | ellipse, accent fill |
| Label | 5.5cm | (icon Y + 0) | 25cm | 1.2cm | sans bold 18pt |
| Description | 5.5cm | (icon Y + 1.3cm) | 25cm | 1.8cm | sans ≥ 16pt (H4 floor), muted |
**Use this when** you have 3 short vertical points that benefit from a visual anchor per row — Solution mechanism, Value pillars, Product loop. Choose C.5b (2×2 grid) when items are parallel and you have exactly 4; choose a horizontal 5-across variant when icons should read side-by-side (e.g. 5-step process).
### C.5b 2×2 Feature grid (4 parallel items)
4 rounded cards, 2 columns × 2 rows. Use when you have exactly 4 parallel items (product pillars, service types, feature quadrants).
```
+-----------------------------+
| Title |
| |
| +---------+ +---------+ |
| | (o) T1 | | (o) T2 | |
| | body | | body | |
| +---------+ +---------+ |
| +---------+ +---------+ |
| | (o) T3 | | (o) T4 | |
| | body | | body | |
| +---------+ +---------+ |
+-----------------------------+
```
| Element | X | Y | Width | Height | Font / size |
|---|---|---|---|---|---|
| Slide title | 2cm | 1cm | 29.87cm | 2.5cm | serif bold 32pt |
| Card 1 bg (top-left) | 1.5cm | 4cm | 14.5cm | 7cm | roundRect |
| Card 2 bg (top-right) | 17.5cm | 4cm | 14.5cm | 7cm | roundRect |
| Card 3 bg (bottom-left) | 1.5cm | 12cm | 14.5cm | 7cm | roundRect |
| Card 4 bg (bottom-right) | 17.5cm | 12cm | 14.5cm | 7cm | roundRect |
| Icon ellipse (each card) | card_x + 0.5cm | card_y + 0.5cm | 2cm | 2cm | — |
| Card title (each) | card_x + 3.2cm | card_y + 0.6cm | 10.5cm | 1.8cm | sans bold 16pt |
| Card body (each) | card_x + 0.5cm | card_y + 3cm | 13cm | 3.5cm | sans ≥ 16pt (H4 floor) |
**Use this when** you have exactly 4 parallel items and the eye should land on each equally — 4 product pillars, 4 service tiers, 4 stakeholder types. 3 items feel lonely in a 2×2; 5+ items break the grid — go to a 3×2 (see pptx v2 §(d) grid math) or C.5 row pattern.
> **Z-order canon (critical).** Each card's `roundRect` background must be added immediately before that card's icon / title / body shapes in the batch JSON — pptx paints in insertion order, so a background added after its text paints over and hides the text. When building with `officecli batch`, follow the per-card sequence `bg → ellipse → title → body` strictly. Pattern and z-order details → see pptx v2 §Recipe (c) z-order canon; reuse grid math from pptx v2 §(d) for non-2×2 counts.
**Dark-background variant.** Change card fill from `F0F4F8` (light) to a lighter-dark shade like `1A2540` and bump body text to `FFFFFF` / `E8E8E8`. Palette variables (e.g. `$MUTED`) do NOT expand inside single-quoted heredocs — write the literal hex (`64748B`) in the JSON.
---
## Key-slide recipes (10 essentials)
The 10 slides every pitch deck carries. Each recipe below gives: **visual outcome** (what the slide looks like from 3m away) + **runnable block** (≤ 18 lines) + **QA one-liner**. All recipes inherit pptx v2 palettes, grid math, type hierarchy, and `--prop tailEnd=triangle` on every connector. Recipes reference the Slide Patterns above: Cover reuses C.1; Problem / Why-Now reuse C.2; Traction / Financials reuse C.4; Feature / pillar slides reuse C.5b. `$FILE` is your deck file.
**Long-title wrap rule.** A 36pt+ title that wraps to 2 lines: add `height` (e.g. 2cm → 3.5cm) — never drop the font below 36pt. Titles < 36pt on a pitch deck read as timid regardless of content.
> **Chart `series1.color=` on `add` works** (applies to every chart recipe below). Passing `--prop series1.color=` (or `series2.color=`, …) on chart `add` applies the series color and exits 0. Verify with a readback if you like: `officecli get "$FILE" "/slide[N]/chart[1]/series[1]" --json | jq '.data.results[0].format.color'`.
### (1) Cover slide — company · tagline · round · date
**Visual outcome.** Dark navy fill, centered 44pt company name, 20pt one-line tagline underneath, small 16pt meta line at the bottom with round + amount + date. Thin brand band at the very bottom (0.5cm high) in the accent color.
```bash
officecli add "$FILE" / --type slide --prop layout=blank --prop background=1E2761
officecli add "$FILE" "/slide[1]" --type shape --prop name=BrandBand \
--prop geometry=rect --prop fill=CADCFC \
--prop x=0cm --prop y=18.5cm --prop width=33.87cm --prop height=0.55cm
officecli add "$FILE" "/slide[1]" --type shape --prop name=CoverTitle --prop text="Acme DevOps" \
--prop x=2cm --prop y=7cm --prop width=29.87cm --prop height=3cm \
--prop font=Georgia --prop size=44 --prop bold=true --prop color=FFFFFF --prop align=center --prop fill=none
officecli add "$FILE" "/slide[1]" --type shape --prop name=Tagline --prop text="Kubernetes observability, built for production at scale" \
--prop x=2cm --prop y=10.5cm --prop width=29.87cm --prop height=1.5cm \
--prop font=Calibri --prop size=20 --prop color=CADCFC --prop align=center --prop fill=none
officecli add "$FILE" "/slide[1]" --type shape --prop name=CoverMeta --prop text='Series B · $35M · April 2026' \
--prop x=2cm --prop y=15cm --prop width=29.87cm --prop height=1.2cm \
--prop font=Calibri --prop size=16 --prop color=FFFFFF --prop align=center --prop fill=none
```
**QA.** Cover has 4 discrete elements (brand band + title + tagline + meta). 80%-whitespace covers fail the pptx "cover ≥ 60% filled" floor.
**Consumer variant (3-second grab).** Consumer decks (B2C app / hardware / D2C) should add a single dominant motif — hero product shot, oversized company name (60–96pt), or symbolic mark (crescent moon / abstract geometric). Replace the 44pt title with an 80–96pt name + one motif shape (`--type shape --prop geometry=ellipse --prop fill=<accent>` for an abstract mark, or `picture` at ~40% of slide for a product hero). Keep tagline + round + date identical. SaaS / B2B may skip — the typographic-only cover is sufficient.
### (2) Problem slide — industry pain in 1 sentence + 3 data cards
**Visual outcome.** 36pt title stating the pain (not "The Problem"). Below, three equal-width data cards across the slide: each a giant number (40pt) + one-line qualifier (16pt) + source footnote (12pt gray).
Grid math for 3 cards, 1.5cm margins, 0.76cm gap: `usable = 33.87 − 3 − 2·0.76 = 29.35`, `col_width = 29.35 / 3 = 9.78cm`. x-positions: `1.5 / 12.04 / 22.58`.
```bash
SLIDE=2 # second slide, after cover. Adjust from your build order.
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Kubernetes debugging burns 12 engineering hours / incident" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2.5cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"PC1","geometry":"roundRect","fill":"F5F7FA","x":"1.5cm","y":"5cm","width":"9.78cm","height":"10cm"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"73%","x":"1.5cm","y":"6cm","width":"9.78cm","height":"3cm","font":"Georgia","size":"60","bold":"true","color":"1E2761","align":"center","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"of incidents take > 1 hour to diagnose","x":"1.5cm","y":"9.5cm","width":"9.78cm","height":"3cm","font":"Calibri","size":"18","color":"333333","align":"center","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Source: 2025 DORA Report","x":"1.5cm","y":"13cm","width":"9.78cm","height":"1cm","font":"Calibri","size":"12","italic":"true","color":"666666","align":"center","fill":"none"}}
]
EOF
# Repeat the 4-block pattern at x=12.04cm and x=22.58cm for cards 2 and 3.
```
**QA.** `officecli query "$FILE" 'shape:contains("Source")'` returns ≥ 3 (every claim carries a source). If zero sources, VCs will not trust a single number.
### (2b) Why Now slide — Consumer / Seed / early A must-have
**Visual outcome.** 3 cards across: each = **trigger headline** (24pt bold) + **data point** (60pt number or date) + **one-line implication** (16pt) + **source footnote** (12pt gray). Reuse Problem grid math (`col=9.78cm`, x = `1.5 / 12.04 / 22.58`). §赛道 Consumer row 2 must-have; Seed / early A in any vertical benefits when "market window" IS the thesis.
```bash
SLIDE=3
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Why now: three converging triggers" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2.5cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
# Card 1 (x=1.5cm) — trigger / data / implication / source. Repeat at x=12.04cm and x=22.58cm.
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"geometry":"roundRect","fill":"F5F7FA","x":"1.5cm","y":"5cm","width":"9.78cm","height":"10cm"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"BOM cost","x":"1.5cm","y":"5.5cm","width":"9.78cm","height":"1.2cm","font":"Calibri","size":"24","bold":"true","color":"1E2761","align":"center","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"−90%","x":"1.5cm","y":"7cm","width":"9.78cm","height":"3cm","font":"Georgia","size":"60","bold":"true","color":"B85042","align":"center","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Wearable BOM fell 90% since 2021; sub-$40 retail now viable","x":"1.5cm","y":"11cm","width":"9.78cm","height":"2cm","font":"Calibri","size":"16","color":"333333","align":"center","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Source: IDC Wearables Teardown 2025","x":"1.5cm","y":"13.5cm","width":"9.78cm","height":"1cm","font":"Calibri","size":"12","italic":"true","color":"666666","align":"center","fill":"none"}}
]
EOF
# Card 2 pattern: Oura IPO 2024 / +$2.4B valuation / category proven. Card 3: On-device LLM (Llama 3.2) / Q4-24 / privacy moat viable.
```
**QA.** 3 cards, each with a date/year citation in the source footnote, each card ≤ 30 words. `officecli query "$FILE" 'shape:contains("2024")'` + `'shape:contains("2025")'` ≥ 2 combined (timing anchors visible).
### (3) Solution slide — product in one sentence + 3-step "how it works"
**Visual outcome.** 36pt title naming the product pattern (not "Our Solution"). Below: 3 or 4 rounded boxes horizontally at y=7cm with elbow connectors + triangle arrowheads. Each box = one verb (observe / correlate / resolve). Reuse pptx Recipe (c) flowchart — orchestration, not a new primitive.
```bash
# Title — "a product pattern, not a brand slogan".
# Good: "Auto-correlate K8s events across 3 data planes in 90 seconds"
# Bad: "The future of observability"
SLIDE=4
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop name=SolTitle \
--prop text="Correlate K8s events across 3 data planes in 90 seconds" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2.2cm \
--prop font=Georgia --prop size=32 --prop bold=true --prop color=1E2761 --prop fill=none
# 3 boxes across: gap = (33.87 − 3 − 3·7) / 2 = 4.93cm; x = 1.5, 13.43, 25.36
# Connectors + arrowheads: --prop tailEnd=triangle ALWAYS (pptx Known Issues C-P-5..6).
# Full batch block → see pptx v2 §Creating and Editing (c) 4-step flowchart; swap N from 4 boxes to 3.
```
**Product-pattern title rule.** The solution title is a verb + differentiated mechanism + metric. "Observe / Correlate / Resolve" is generic; VCs read it as any APM vendor. "Correlate K8s events across 3 data planes in 90 seconds" is specific; VCs read it as an insight.
**QA.** Count connectors: `officecli query "$FILE" 'connector' --json | jq '.data.results | length'` ≥ (step_count − 1). Every connector must have `tailEnd=triangle` — `view annotated` confirms arrowhead direction. Title must be ≤ 12 words (one breath).
### (4) Market slide — TAM / SAM / SOM nested columns
**Visual outcome.** 36pt title "Market: $X.YB growing Z% CAGR". Below: three horizontal bars (or three stacked nested rectangles), labeled TAM / SAM / SOM with dollar values + growth rate. Bottom footnote cites **top-down vs bottom-up source** — pick one methodology per deck, don't mix.
```bash
# Use a pptx column chart with 3 values. Categories = TAM,SAM,SOM. Source annotation is a separate shape.
SLIDE=5
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="$42B observability market, 18% CAGR" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type chart --prop chartType=bar \
--prop series1.name="USD (billions)" --prop series1.values="42,8.4,0.62" --prop series1.color=1E2761 \
--prop categories="TAM,SAM,SOM (5-yr)" \
--prop x=2cm --prop y=4cm --prop width=22cm --prop height=12cm \
--prop title='Market sizing — bottom-up by enterprise count × ACV'
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='Source: Gartner 2025 APM Magic Quadrant; SAM = 20% of TAM (K8s-first shops); SOM = 7.4% of SAM over 5 years at 18-24% share.' \
--prop x=2cm --prop y=16.5cm --prop width=29.87cm --prop height=2cm \
--prop font=Calibri --prop size=12 --prop italic=true --prop color=666666 --prop fill=none
```
**QA.** Top-down vs bottom-up MUST be declared in the source footnote. A TAM without methodology reads as fabricated.
### (5) Product slide — screenshot + 3 bullets OR 3-card feature grid
**Visual outcome.** Two layout options: (a) hero product screenshot on the left (60% of slide), 3 one-line feature bullets on the right (each ≥ 18pt body, no bullets under bullets). (b) 3 feature cards with one icon / screenshot thumbnail each. Pick (a) for consumer / app products, (b) for B2B / infrastructure.
```bash
# (a) screenshot + bullets — consumer pattern
officecli add "$FILE" "/slide[$SLIDE]" --type picture --prop src=product_hero.png \
--prop x=1cm --prop y=4cm --prop width=18cm --prop height=13cm
officecli set "$FILE" "/slide[$SLIDE]/picture[1]" --prop alt="Product UI: dashboard with 12 K8s clusters, live correlation graph"
# Right column bullets (each as a separate shape so sizes stay explicit)
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Auto-correlate across 3 data planes" \
--prop x=20cm --prop y=5cm --prop width=12cm --prop height=1.5cm \
--prop font=Calibri --prop size=20 --prop bold=true --prop color=1E2761 --prop fill=none
# Repeat for bullets 2 and 3 at y=7.5cm / y=10cm.
```
**QA.** Picture alt text present (`query 'picture:no-alt'` = empty). Bullets each ≥ 18pt. No "Lorem"/"product name here"/`{{...}}` tokens.
### (6) Business model slide — unit econ or revenue model
**Visual outcome.** Decision tree by vertical:
- **SaaS / Enterprise (Series A+)** — 4 KPI callouts: CAC / LTV / Payback / GM (reuse pptx Recipe (e)).
- **Consumer / D2C** — AOV · repeat-purchase rate · contribution margin · blended CAC.
- **Marketplace** — GMV / take-rate / liquidity metric / cohort retention.
- **Bio / Deep tech** — revenue model (license / milestone / royalty split) with assumed ranges.
Title names the dominant metric (e.g. "LTV:CAC 4.7x · 14-month payback · 78% gross margin"), not "Business Model". Full 4-card batch block → see pptx v2 §(e) KPI callouts.
```bash
# SaaS pattern: KPI card values + sub-label + gray VC-floor context under each.
# Card 1 (LTV): big number "$420K", sub "Lifetime value", context "floor: ARPU × GM / churn"
# Card 2 (CAC): big number "$90K", sub "Acquisition cost", context "fully-loaded S&M spend"
# Card 3 (Payback): big number "14 mo", sub "CAC payback", context "VC floor: < 18 mo"
# Card 4 (GM): big number "78%", sub "Gross margin", context "SaaS floor: 70%+"
# Grid math for 4 cards across: usable = 33.87 − 3 − 3·0.76 = 28.59, col = 7.15cm
# → Full batch template → pptx v2 §(e). Adapt card count 3→4 and card width 9.78cm→7.15cm.
```
**QA.** For Series B+, all four of {CAC, LTV, payback, GM} present: `officecli query "$FILE" 'shape:contains("CAC")'` ≥ 1 AND `shape:contains("LTV")'` ≥ 1 AND `shape:contains("payback")'` ≥ 1 AND `shape:contains("gross margin")'` ≥ 1.
### (7) Traction slide — ARR curve that starts at 0
**Visual outcome.** Line chart taking 60% of slide width; ARR on y-axis **starting at 0** (not at 80% of current value — the VC hockey-stick lie). Right-side commentary card: single giant number (current ARR) + growth rate + 2-3 milestones. If Series B+, second row: cohort retention snippet or logo wall.
```bash
SLIDE=7
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='ARR: $0 → $18M in 24 months' \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type chart --prop chartType=line \
--prop series1.name=ARR --prop series1.values="0.2,0.6,1.4,3.2,6.1,11.3,15.8,18.0" --prop series1.color=1E2761 \
--prop categories="Q1-24,Q2-24,Q3-24,Q4-24,Q1-25,Q2-25,Q3-25,Q4-25" \
--prop x=1.5cm --prop y=4cm --prop width=21cm --prop height=13cm \
--prop title='Quarterly ARR ($M) — y-axis anchored at 0' \
--prop axismin=0
# Right callout
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop geometry=roundRect --prop fill=1E2761 --prop line=none \
--prop x=23.5cm --prop y=4cm --prop width=8.8cm --prop height=13cm
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='$18M' \
--prop x=23.5cm --prop y=5cm --prop width=8.8cm --prop height=3cm \
--prop font=Georgia --prop size=64 --prop bold=true --prop color=FFFFFF --prop align=center --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="ARR · +312% YoY · NRR 128%" \
--prop x=23.5cm --prop y=9cm --prop width=8.8cm --prop height=3cm \
--prop font=Calibri --prop size=18 --prop color=CADCFC --prop align=center --prop fill=none
```
**`--prop axismin=0` is load-bearing** — without it, pptx auto-scales the y-axis to start near the lowest value. That is the hockey-stick lie. Gate 6 greps this below.
**QA.** ARR curve chart must carry `axismin=0`. `officecli get "$FILE" "/slide[$SLIDE]/chart[1]" --json | jq .format.axisMin` returns `0` (CLI emits camelCase `axisMin` in readback even though input prop is lowercase `axismin`).
### (8) Team slide — avatars + names + prior companies (not just a wall)
**Visual outcome.** 3- or 4-card row across the middle of the slide. Each card: picture (6×6cm) on top; name (20pt bold); role (16pt); **prior company + title** (16pt italic, 1 key line); optional LinkedIn URL footer (12pt). Team slide with just headshots and names reads as amateur.
```bash
SLIDE=11
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Team: 3 prior exits, 42 years combined K8s" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
# Card 1 — CEO
officecli add "$FILE" "/slide[$SLIDE]" --type picture --prop src=alice.jpg \
--prop x=2cm --prop y=5cm --prop width=6cm --prop height=6cm
officecli set "$FILE" "/slide[$SLIDE]/picture[1]" --prop alt="Alice Chen, CEO — portrait"
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Alice Chen" \
--prop x=2cm --prop y=11.5cm --prop width=6cm --prop height=1cm \
--prop font=Georgia --prop size=20 --prop bold=true --prop color=1E2761 --prop align=center --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="CEO" \
--prop x=2cm --prop y=12.8cm --prop width=6cm --prop height=0.8cm \
--prop font=Calibri --prop size=16 --prop color=333333 --prop align=center --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="ex-Datadog Director (Series C → IPO); led K8s observability GTM $40M → $200M ARR" \
--prop x=2cm --prop y=13.8cm --prop width=6cm --prop height=2.5cm \
--prop font=Calibri --prop size=14 --prop italic=true --prop color=333333 --prop align=center --prop fill=none
# Repeat for Card 2 (CTO, x=10cm) and Card 3 (VP Eng, x=18cm) — 3 cards × 5-6 shapes each.
```
Prior companies carry **credibility density**. VCs read "ex-Datadog Director + led $40M → $200M" in 2 seconds; they read "co-founder, passionate" in 0 seconds (because they skip it). Advisors, if shown, go in a smaller row below with a single logo each.
**Arrangement helper.** 3 cards: `col=9.78cm, x=1.5/12.04/22.58`. 4 cards: `col=7.15cm, x=1.5/9.41/17.32/25.23`. 5 cards: `col=5.85cm, x=1.5/7.75/14.0/20.25/26.5` (0.4cm gap, tighter). 6+ or asymmetric → 2-row grid (3×2 / 3×3); see pptx v2 §(d) grid math.
**QA.** `officecli query "$FILE" 'shape:contains("ex-")'` + `'shape:contains("prior")'` + `'shape:contains("former")'` ≥ 1 per team member. If zero, you have a portfolio, not a team.
### (9) Financials slide — 4-year plan + honest assumptions
**Visual outcome.** Column chart: 4 years × (revenue, gross margin $, EBITDA). Right-side card: 3-bullet assumption panel (ARPU assumption, win-rate assumption, churn assumption). Title names the trajectory ("$18M → $85M by FY29"), not "Financial Projections".
Reuse pptx Recipe (b) chart + commentary. Pitch-specific: ASSUMPTIONS column on the right is **load-bearing** — a 4-year plan without visible assumptions reads as aspirational. VCs will ask what's behind every number anyway; surface it.
Left 2/3 — slide + title + 3-series column chart:
```bash
SLIDE=17
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='$18M → $85M ARR by FY29' \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type chart --prop chartType=column \
--prop series1.name="Revenue ($M)" --prop series1.values="18,34,58,85" --prop series1.color=1E2761 \
--prop series2.name="Gross Margin ($M)" --prop series2.values="14,26,45,68" --prop series2.color=CADCFC \
--prop series3.name="EBITDA ($M)" --prop series3.values="-6,-2,8,22" --prop series3.color=B85042 \
--prop categories="FY26,FY27,FY28,FY29" \
--prop x=1.5cm --prop y=4cm --prop width=20cm --prop height=13cm \
--prop title='4-year plan — revenue, GM, EBITDA ($M)'
```
Right 1/3 — assumptions commentary card:
```bash
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop geometry=roundRect --prop fill=F5F7FA --prop line=none \
--prop x=22.5cm --prop y=4cm --prop width=9.8cm --prop height=13cm
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Key Assumptions" \
--prop x=23cm --prop y=4.5cm --prop width=8.8cm --prop height=1.2cm \
--prop font=Georgia --prop size=20 --prop bold=true --prop color=1E2761 --prop fill=none
# 5 assumption bullets as 5 separate paragraph shapes at y=6, 7.5, 9, 10.5, 12cm — size=14, italic=true.
# Keep each bullet ≤ 14 words so 8.8cm width fits without wrap.
```
**Assumptions panel is load-bearing.** A 4-year plan without visible assumptions reads as aspirational. VCs ask what's behind every number anyway — surface the three or four assumptions that drive the curve.
**QA.** `officecli query "$FILE" 'shape:contains("assumption")'` OR `'shape:contains("Assumes")'` ≥ 1. If zero, add the panel.
### (10) The Ask — hero number + 4-bucket Use-of-Funds + runway
**Visual outcome.** Dark fill (match cover). Hero number in the center top: `$35M` at 96pt white. Below, a 4-bucket pie OR a 4-card row listing **Engineering 40% / GTM 35% / G&A 15% / Reserve 10%**. Bottom line: "18-month runway to $40M ARR" (next milestone, not "until next round").
```bash
SLIDE=20
officecli add "$FILE" / --type slide --prop layout=blank --prop background=1E2761
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='$35M Series B' \
--prop x=2cm --prop y=2cm --prop width=29.87cm --prop height=4cm \
--prop font=Georgia --prop size=88 --prop bold=true --prop color=FFFFFF --prop align=center --prop fill=none
officecli add "$FILE" "/slide[$SLIDE]" --type chart --prop chartType=pie \
--prop series1.name="Use of Funds" --prop series1.values="40,35,15,10" \
--prop categories="Engineering,Go-to-Market,G&A,Reserve" \
--prop colors="CADCFC,B85042,97BC62,FFFFFF" \
--prop x=6cm --prop y=7cm --prop width=12cm --prop height=10cm \
--prop title="Use of Funds — 4 buckets"
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='18 months runway to $40M ARR and Series C' \
--prop x=2cm --prop y=17cm --prop width=29.87cm --prop height=1.5cm \
--prop font=Calibri --prop size=22 --prop color=CADCFC --prop align=center --prop fill=none
```
**4-bucket convention.** Engineering / GTM / G&A / Reserve is the canonical breakdown. Typical Series A ranges: Eng 40-50%, GTM 30-40%, G&A 10-15%, Reserve 5-10%. Series B shifts 5-10 points from Eng to GTM.
**QA.** `officecli query "$FILE" 'shape:contains("Use of Funds")'` ≥ 1. Pie chart present on ask slide. Runway + milestone on ask slide.
### (11) Pipeline chart — Bio / Deep Tech must-have
**Visual outcome.** Horizontal swimlane. Left column = candidate name; 4 stage columns to the right (Preclinical / Ph1 / Ph2 / Ph3 for bio — or TRL1-3 / TRL4-6 / TRL7-8 / TRL9 for deep tech). Each row's bar extends to its current stage; darker fill for later stages. NCT / trial-ID footer below. §赛道 row 5 Bio must-have; SaaS / Consumer skip.
Grid math: usable `= 30.87cm`, candidate col `= 7cm`, stage cols `= (30.87 − 7) / 4 = 5.97cm` each, row height `= 2.3cm`. Stage col x: `8.5 / 14.47 / 20.44 / 26.41`.
```bash
SLIDE=6
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Pipeline: 3 candidates across Ph1–Ph3" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
# 4 stage headers + candidate row 1 (HLX-201 at Ph2, bar width = 3·5.97 = 17.91cm) in one batch.
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Preclinical","x":"8.5cm","y":"4cm","width":"5.97cm","height":"1cm","font":"Calibri","size":"16","bold":"true","color":"333333","align":"center","fill":"F5F7FA"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Phase 1","x":"14.47cm","y":"4cm","width":"5.97cm","height":"1cm","font":"Calibri","size":"16","bold":"true","color":"333333","align":"center","fill":"F5F7FA"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Phase 2","x":"20.44cm","y":"4cm","width":"5.97cm","height":"1cm","font":"Calibri","size":"16","bold":"true","color":"333333","align":"center","fill":"F5F7FA"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"Phase 3","x":"26.41cm","y":"4cm","width":"5.97cm","height":"1cm","font":"Calibri","size":"16","bold":"true","color":"333333","align":"center","fill":"F5F7FA"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"HLX-201 (lead)","x":"1.5cm","y":"5.5cm","width":"7cm","height":"1.5cm","font":"Calibri","size":"18","bold":"true","color":"1E2761","align":"left","fill":"none"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"geometry":"roundRect","fill":"1E2761","x":"8.5cm","y":"5.7cm","width":"17.91cm","height":"1.1cm","line":"none"}}
]
EOF
# Repeat rows 2 & 3 at y=7.8cm / y=10.1cm with bar widths per stage (Ph1=5.97cm, Ph1-Ph2=11.94cm, Ph1-Ph3=17.91cm).
# NCT footer full-width at y=16.8cm.
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text='NCT05021323 (HLX-201, Ph2, n=48) · NCT06142091 (HLX-304, Ph1, n=24) · IND-filed Q1-26 for HLX-412' \
--prop x=1.5cm --prop y=16.8cm --prop width=30.87cm --prop height=1.2cm \
--prop font=Calibri --prop size=12 --prop italic=true --prop color=666666 --prop fill=none
```
**QA.** `officecli query "$FILE" 'shape:contains("NCT")' --json | jq '.data.results | length'` ≥ 1. Bar colors darken across stages (`CADCFC` preclinical-only, `1E2761` Ph2-reached).
### (12) Competitive comparison table — Series B+ essential
**Visual outcome.** 5–7 rows × 4–6 cols. Column 1 = competitor name (optional logo shape beside); rest = differentiators (speed / price / integrations / margin / coverage). **Last row = your company, fill highlighted** in an accent color (CADCFC / 97BC62); competitor rows gray. Every Series B+ deck needs this (SaaS: Datadog / New Relic / Splunk; Bio: Kite / Novartis / BMS).
```bash
SLIDE=13
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[$SLIDE]" --type shape --prop text="Competitive landscape" \
--prop x=1.5cm --prop y=1.2cm --prop width=30.87cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
# Inline table via --prop data= (per-cell r#c# is silently ignored / not honored — use data=). Single-quote the data value — '$15/host' would strip.
officecli add "$FILE" "/slide[$SLIDE]" --type table \
--prop data='Competitor,Speed,Price,Integrations,Margin;Datadog,12 min,$15/host,680,75%;New Relic,18 min,$25/host,520,68%;Splunk,45 min,$45/GB,310,62%;You (Acme DevOps),90 sec,$8/host,1200,82%' \
--prop style=medium1 --prop headerFill=1E2761 \
--prop x=1.5cm --prop y=4cm --prop width=30.87cm --prop height=12cm
# Highlight your row: loop over /slide[$SLIDE]/table[1]/tr[5]/tc[1..5] and set cell fill to CADCFC.
```
**QA.** `officecli query "$FILE" 'table' --json | jq '.data.results | length'` ≥ 1. Row count ≥ 4 (you + ≥ 3 named competitors). Your row visually distinct via cell fill (Gate 5b visual check — table style alone does not highlight one row).
## Numbers convention (pitch-specific)
A terse convention table — **not a finance tutorial**. If you don't already know what these mean, pause the deck and ask the user for the values; don't guess.
| Metric | Shape | Floor / convention |
|---|---|---|
| **TAM** | `$X.YB`, one methodology | Either top-down (analyst report) or bottom-up (count × ACV). Never both; never neither. |
| **SAM** | `$X.YB`, fraction of TAM you serve | Typically 15 – 30% of TAM for verticalized SaaS; higher for horizontal |
| **SOM** | `$X.YB` at year N | Realistic 5-yr share: 5 – 15% of SAM for early stage |
| **ARR** | MRR × 12. NOT revenue. | SaaS only; contracts on books, net of churn |
| **MRR** | Monthly recurring | ARR / 12; do not confuse with monthly revenue |
| **NRR (Net Revenue Retention)** | %, trailing 12 mo | VC floor: > 100% acceptable, > 115% strong, > 130% exceptional |
| **CAC** | $ fully-loaded | Sales + marketing spend / new logos acquired |
| **LTV** | $ | ARPU × gross margin × (1 / churn rate) |
| **LTV:CAC** | ratio | VC floor: 3x OK, > 4x strong, > 5x exceptional |
| **CAC payback** | months | VC floor: < 18 mo OK, < 12 mo strong |
| **Gross margin** | % | SaaS floor 70%, strong 80%+; marketplace 15-40%; hardware 30-50% |
| **Burn / runway** | $/month + months | Gross burn vs net burn — label which; runway to specific milestone |
| **Use of Funds** | 4-bucket pie | Engineering / Go-to-Market / G&A / Reserve — see Ask slide recipe |
**Rule.** Every number on a deck carries a unit. `18%` or `18M` alone is ambiguous — write `$18M ARR` / `18% NRR growth`. `TBD`, `coming soon`, `(fill in)`, `lorem`, `xxxx` in numeric slots = immediate VC disqualification. Gate 6 greps these below.
## VC ship-check (6 red flags / positive signals)
What the VC reads in the first 30 seconds. Six one-line conditions — every "FAIL" below is an instant round-killer; fix before delivering.
| # | Red flag (FAIL if present) | Positive signal (shipwise) |
|---|---|---|
| 1 | Cover without round + amount + date | `Company · tagline · Series X · $YM · Date` in 4 lines |
| 2 | TAM > $100B without a cited source / methodology | TAM clearly labeled bottom-up OR top-down with a visible 2024+ source |
| 3 | Traction chart y-axis does not start at 0 (hockey-stick lie) | Line chart `axismin=0`; growth shape honest |
| 4 | Team slide: headshots + names only, no prior companies | Every member: prior company + role + 1 achievement metric |
| 5 | Ask slide missing Use-of-Funds breakdown | `$XM` hero + 4-bucket pie (Eng / GTM / G&A / Reserve) + runway + next milestone |
| 6 | `TBD` / `lorem` / `xxxx` / `{{...}}` / `(fill in)` anywhere | `view text` clean — zero placeholder tokens |
**Common Series-specific failures.**
- **Series A specific** — bottom-up TAM calculated from a fictional enterprise-count × ACV (no reference customers to anchor the count); `CAC / LTV` shown with < 12 months of data (statistically meaningless).
- **Series B specific** — no unit-econ slide at all; CAC payback > 24 months without a "we're pre-scale, here's the plan" narrative; logo wall < 8 customers.
- **Series C specific** — no moat / defensibility slide; revenue growth shown without margin trajectory; international expansion stated but no specific launch plan / hires.
The Delivery Gate 6 block below executes checks 1–6 above via grep + query. Gate 5b fresh-eyes covers the visual judgments (hockey stick, team credibility) that grep can't see.
## Traction triple-pattern (ARR + milestones + logos)
For Series B+, traction often spans 2 slides: one for the chart + callout (recipe 7 above), one for **milestone timeline + logo wall**. Timeline = 4-6 horizontal dates with one-line events. Logo wall = 12-20 customer logos in a 4×N or 5×N grid, muted monochrome so no single brand dominates.
```bash
# Milestone timeline: 5 dates as circles on a horizontal line at y=8cm.
# Use pptx shapes (ellipse preset) + connectors (shape=straight) between them.
# Each milestone = ellipse at y=8cm + date label above + event description below.
# → See pptx v2 Recipe (d) row 9 (Roadmap timeline) for the canonical pattern.
# Logo wall: pictures in a 5×N grid. Typical spacing: logo width = 5cm, height = 2cm, gap = 0.4cm.
# grid math for 5 logos across, 1.5cm edge margin: usable = 33.87 − 3 − 4·0.4 = 29.27, col = 5.85cm
# (use 5cm logo width centered in each 5.85cm column)
```
**QA.** Logo wall should have ≥ 8 logos for Series B+, ≥ 4 for Series A. Fewer = "lighter than it looks"; more than 20 = pixel noise.
## QA — Delivery Gate (executable)
**Assume there are problems.** First render is almost never correct. Pitch decks fail at two layers: **structural** (schema, token leaks — caught by pptx v2 Gates 1–3) and **narrative** (wrong stage, missing unit econ, TAM unsourced — the checks that make pptx v2 Gate 5b + Gate 6 indispensable). Every check must print its success message.
### Gates 1–5a — inherited from pptx v2 verbatim
→ see pptx v2 §Delivery Gate L637-679. Copy-paste the full block:
- **Gate 1** — `validate` schema check (whitelist `ChartShapeProperties` warnings per C-P-2).
- **Gate 2** — token leak via `view text` grep (`$xxx$`, `{{...}}`, `<TODO>`, `lorem`, `xxxx`, empty `()`/`[]`, `\$`/`\t`/`\n` literals).
- **Gate 3** — hyperlink `rPr` schema trap (C-P-1) — zero `<a:rPr><a:hlinkClick>`.
- **Gate 4** — slide-order sanity — cover first, dividers before sections, closing last.
- **Gate 5a** — dark-on-dark contrast — every fill in `{1E2761, 0A1628, 8B1A1A, 2C5F2D, 36454F}` must declare near-white textColor. **This includes charts rendered on that fill**: chart `title.textColor`, `legend.textColor`, axis text default to dark and read as invisible on dark backgrounds — set them explicitly, or place the chart on a light card inside the dark slide.
Do not skip or reorder these five. Every pptx-layer defect caught by Gates 1–5a also fires on pitch decks.
**Gate 2b — pitch-specific shell-strip signatures (MANDATORY).** Gate 2 misses `$35M` that zsh silently stripped to empty (no residue to grep). Run this after Gate 2:
```bash
# $XXM stripped by zsh leaves bare " M ARR" / " M raised" / "Series [A-C] · M" patterns.
STRIP=$(officecli view "$FILE" text | grep -niE '(^|[^A-Za-z0-9])M (ARR|raised|Series|runway|round|raise)|Series [A-C] · M( |$)|runway · M|raised · M|raising ·? M')
[ -z "$STRIP" ] && echo "Gate 2b OK (no \$-strip signatures)" || { echo "REJECT Gate 2b (likely zsh \$-strip — re-issue with single quotes):"; echo "$STRIP"; exit 1; }
```
Fix: re-issue the offending `add`/`set` with single quotes around the text value (`--prop text='Series B · $35M'`, not double quotes). The same strip hits **chart series names / axis titles** (`--prop name="营收 ($M)"` → legend shows `营收 ()`): single-quote every chart prop carrying `$`.
### Gate 5b — Visual audit via HTML preview (MANDATORY, NOT optional)
Gates 1–5a are token-grep defenses. **They cannot see a rendered slide.** This step is the only visual-assembly check. Do not skip.
Run `officecli view "$FILE" html` and Read the returned HTML. Walk every slide and answer, for EACH (inherits pptx v2 Gate 5b checklist; pitch-specific additions marked ⭐):
- **overlap**: do any text shapes overlap each other or a chart?
- **dark-on-dark**: is any text on a fill where fill brightness < 30% AND text brightness < 80%?
- **divider overlap**: any giant decorative number (01/02/03 at 100pt+) colliding with the divider title text?
- **order sanity**: does the slide sequence match your stage-appropriate narrative outline?
- **missing arrowheads**: do flowchart/decision-tree connectors show direction, or plain lines?
- ⭐ **traction y-axis**: does every ARR / revenue / growth line chart start at 0 on the y-axis? (Not 80% of current — that is the hockey-stick lie.)
- ⭐ **team credibility**: does every team-slide card show a prior company or prior title? (Cards with just headshot + name = reject.)
- ⭐ **TAM / market number credibility**: is the TAM under $100B for a niche market, or if ≥ $100B, is a methodology source cited? (A claimed `$500B TAM` with no source is an auto-reject red flag.)
- ⭐ **Use-of-Funds pie**: does the ask slide carry a 4-bucket pie (Engineering / GTM / G&A / Reserve) or a 4-card row with %s?
- ⭐ **narrative completeness**: is the order cover → problem → solution → market → product → model → traction → team → financials → ask, or your stage-appropriate permutation from §Stage diagnosis?
**Instruction.** Run `officecli view "$FILE" html` and Read the HTML. Walk every slide against the questions below. If rendering chart colors, animations, or zoom — those only show in the target viewer (PowerPoint / Keynote / WPS); ask the user to open `.pptx` directly for those runtime features.
> For every slide:
> (a) Are slides in VC narrative order (cover → problem → solution → market → product → model → traction → team → financials → ask, with your stage's adjustments)? Flag any out-of-sequence.
> (b) Is every ARR / revenue / growth line chart y-axis anchored at 0? Flag hockey-stick visual lies.
> (c) Does the team slide carry prior-company credentials for each person? (Not just headshot + name.)
> (d) Does every TAM / SAM / SOM claim have a visible source or methodology?
> (e) Does the ask slide have a 4-bucket Use of Funds (Engineering / GTM / G&A / Reserve) and a specific next milestone + runway length?
> (f) Any text overlap, dark-on-dark, off-slide geometry, missing arrowheads, placeholder tokens (`TBD` / `lorem` / `{{...}}` / `xxxx` / empty `()`)?
Report every instance with slide number. If ANY defect — REJECT; do not deliver until fixed.
**Human preview (optional).** If you want the user to visually preview the deck, run `officecli watch "$FILE"` for a live preview the user can open at their own discretion, or have them open the `.pptx` directly in PowerPoint / WPS / Keynote. For final visual verification, open the file in the target presentation viewer.
### Gate 6 — Pitch narrative sanity (executable)
Pitch-specific checks that grep the deck for VC red flags. Every one is a token check — combine with Gate 5b's human read for full coverage.
```bash
FILE="deck.pptx"
# 6.1 — no TBD / lorem / placeholder tokens (stronger than Gate 2 — pitch-specific scope)
LEAK=$(officecli view "$FILE" text | grep -niE 'TBD|lorem|\(fill in\)|xxxx|coming soon|placeholder')
[ -z "$LEAK" ] && echo "Gate 6.1 OK (no placeholder tokens)" || { echo "REJECT Gate 6.1:"; echo "$LEAK"; exit 1; }
# 6.2 — TAM / SAM / SOM presence (Series A+)
TAM_HIT=$(officecli query "$FILE" 'shape:contains("TAM")' --json | jq '.data.results | length')
[ "$TAM_HIT" -ge 1 ] && echo "Gate 6.2 OK (TAM slide present)" || echo "WARN Gate 6.2: no TAM mention — confirm stage is Seed / Bridge if intentional"
# 6.3 — Unit econ presence (Series B+): CAC OR LTV OR payback
CAC_HIT=$(officecli query "$FILE" 'shape:contains("CAC")' --json | jq '.data.results | length')
LTV_HIT=$(officecli query "$FILE" 'shape:contains("LTV")' --json | jq '.data.results | length')
if [ "$CAC_HIT" -ge 1 ] || [ "$LTV_HIT" -ge 1 ]; then
echo "Gate 6.3 OK (unit econ surface)"
else
echo "WARN Gate 6.3: no CAC / LTV — confirm stage Seed/A if intentional, REJECT if Series B+"
fi
# 6.4 — Use of Funds present on ask slide
UOF_HIT=$(officecli query "$FILE" 'shape:contains("Use of Funds")' --json | jq '.data.results | length')
[ "$UOF_HIT" -ge 1 ] && echo "Gate 6.4 OK (Use of Funds)" || { echo "REJECT Gate 6.4: ask slide missing Use of Funds"; exit 1; }
# 6.5 — Team prior-company signal (at least one of ex- / former / prior / previously)
PRIOR_HIT=$(officecli view "$FILE" text | grep -ciE '\b(ex-|former|prior|previously)\b')
[ "$PRIOR_HIT" -ge 1 ] && echo "Gate 6.5 OK (team prior-company)" || { echo "REJECT Gate 6.5: team slide has no prior-company credentials"; exit 1; }
# 6.6 — Traction chart y-axis anchored at 0 (at least one chart must set axismin=0, Series A+)
AXISMIN_HIT=$(officecli query "$FILE" 'chart' --json | jq '[.data.results[]? | select(.format.axisMin == "0" or .format.axisMin == 0 or .format.axismin == "0" or .format.axismin == 0)] | length')
[ "$AXISMIN_HIT" -ge 1 ] && echo "Gate 6.6 OK (traction chart axisMin=0)" || echo "WARN Gate 6.6: no chart sets axisMin=0 — confirm no ARR/revenue line chart, or add --prop axismin=0"
echo "Delivery Gate 6 PASS (token + narrative checks) — proceed to Gate 5b fresh-eyes (MANDATORY)"
```
**Readback key note.** CLI accepts lowercase `axismin` as input (on `--prop axismin=0`) but emits camelCase `axisMin` in `query --json` readback. The jq above accepts both for forward-compat.
Gate 6 is a grep floor. Gate 5b is the visual ceiling. Ship only when both print PASS.
### Honest limit
`validate` catches schema errors, not fundraising errors. A deck passes `validate` with a `$500B TAM` on a $10M market, a team slide of four co-founders with no prior companies, a hockey stick y-axis at 80%, a pitch for a Series B round without unit econ, and an ask slide saying "we're raising some money". Gates 5b + 6 above exist because `validate` cannot catch any of this.
## Known Issues & Pitfalls
→ Base pitfalls (shell escape, `[last()]` in resident, connector `@name=` rejection C-P-6, picture alt two-step C-P-7, animation remove C-P-4, chart color normalization C-P-7): see pptx v2 §Known Issues & Pitfalls C-P-1..7.
Pitch-specific:
- **Stage misidentified.** Series A deck with 6 pages of CAC/LTV math = over-packaged. Series B deck missing unit econ = incomplete. If unsure, re-read §Stage diagnosis before building.
- **Hockey-stick y-axis.** If the line chart's y-axis doesn't start at 0, VCs read it as a visual lie within 2 seconds. Always `--prop axismin=0` on ARR / revenue / growth charts. Gate 6.6 checks this.
- **Team slide = portfolio.** Cards showing only {headshot + name + role} fail VC credibility. Every card needs a prior-company or prior-achievement line. Gate 6.5 checks this.
- **TAM without methodology.** A claimed number with no "top-down" or "bottom-up" source footnote = fabricated. Pick one methodology per deck; don't mix.
- **Use-of-Funds as 3-bucket or 5-bucket.** 4-bucket (Eng / GTM / G&A / Reserve) is convention; departing from it reads as sloppy. Gate 6.4 checks presence.
- **Pitch deck used for a board review / sales deck.** Narrative arc (problem → ask) makes board reviews awkward — route to pptx v2 Recipe (d) 10-slide instead. See §Reverse handoff above.
- **pptx v2 Recipe (d′) 20-slide is a starting point, not a formula.** It is stage-agnostic SaaS. Adjust for your stage + 赛道 via §Stage diagnosis and §赛道 arc templates — never ship (d′) unchanged for a non-SaaS Series A.
## Help pointer
When in doubt: `officecli help pptx`, `officecli help pptx <element>`, `officecli help pptx <element> --json`. Help is the authoritative schema; this skill is the decision guide for fundraising deltas on top of pptx v2.
+568
View File
@@ -0,0 +1,568 @@
---
name: officecli-pptx
description: "Use this skill any time a .pptx file is involved -- as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file; editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions 'deck', 'slides', 'presentation', 'pitch', or references a .pptx filename."
---
# OfficeCLI PPTX Skill
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**This skill teaches what good slides look like, not every command flag. When a property name, enum value, or alias is uncertain, consult help BEFORE guessing.**
```bash
officecli help pptx # List all pptx elements
officecli help pptx <element> # Full element schema (e.g. shape, chart, animation, connector, zoom, group)
officecli help pptx <verb> <element> # Verb-scoped (e.g. add shape, set slide)
officecli help pptx <element> --json # Machine-readable schema
```
Help reflects the installed CLI version. When skill and help disagree, **help is authoritative**. Triggers to run help immediately: `UNSUPPORTED props:` warning, unknown animation preset, `connector.shape=` enum drifts, prop-vs-alias (`lineWidth` vs `line.width`, `color` vs `font.color`).
## Shell & Execution Discipline
**Shell quoting (zsh / bash).** ALWAYS quote element paths (`"/slide[1]/..."`) — zsh globs unquoted `[1]` to `no matches found`. Escaping happens at three layers — keep them separate (the CLI handles the second for you):
1. **Shell.** `$` in a value still belongs to the shell — single-quote the whole value: `--prop text='$15M'`. Double-quoted `"$15M"` gets expanded to `M`. The CLI does NOT unescape `\$` for you.
2. **CLI (`text=`).** The two-char escapes `\n` and `\t` ARE interpreted, consistently across pptx / docx / xlsx — `\n` is a line / paragraph break, `\t` is a tab. To produce a literal backslash-n in text, double it (`\\n`); this is rarely what you want.
3. **JSON (batch heredoc).** The recipes below pipe `cat <<EOF | officecli batch` **unquoted** so `$SLIDE` / `$FILE` expand inside the body. That same unquoted heredoc also expands a literal `$` in a value: `"$1.42"` silently becomes `.42` and `"$2.4B"` becomes `.4B` (`$1` / `$2` are empty shell params). **Escape currency as `\$`** — `"text":"\$1.42"` — which still lets `$SLIDE` expand. `\n` / `\t` inside the JSON work either way. A fully-quoted `<<'EOF'` protects every `$` but then `$SLIDE` won't expand, so only use it when the body has no shell variables. After writing money values, `view text` and confirm the `$` survived.
If in doubt, `view text` after writing and compare character-for-character.
**Incremental execution.** One command → check exit code → continue. A 50-command script that fails at command 3 cascades silently. After any structural op (new slide, chart, animation, connector) run `get` before stacking more.
## Requirements for Outputs
These are the deliverable standards every deck MUST meet. Violating any one = not done, regardless of content quality.
### All decks
**One idea per slide.** If a slide needs a second title to explain what it covers, split it. Dense "everything about X" slides lose the audience inside 3 seconds. Use a section divider to group related one-idea slides, not a mega-slide.
**Explicit type hierarchy — do NOT rely on theme defaults.** Theme defaults drift between masters. Set sizes explicitly on every text shape.
| Element | Minimum | Typical | Min shape height |
|---|---|---|---|
| Slide title | **≥ 36pt** bold | 36–44pt | ≥ 2cm |
| Section / subtitle | ≥ 20pt | 20–24pt | ≥ 1.2cm |
| Body text | **≥ 18pt** | 18–22pt | ≥ 1cm |
| Caption / axis label | ≥ 10pt muted | 10–12pt | ≥ 0.6cm |
Rule of thumb: **min shape height ≈ font_pt × 0.05cm**. An 18pt sublabel in a 0.8cm-tall box will overflow — `view annotated` catches this.
Title must be **≥ 2× body size** (36pt over 20pt works; 28pt over 20pt looks timid). Four legit exceptions to body ≥ 18pt: chart axis labels, legends, footer / page number, and ≤ 5-word KPI sublabels (e.g. "Active users"). Descriptive sentences must be ≥ 18pt. Left-align body; center only titles and hero numbers. If "the cards won't fit", drop cards instead of shrinking font.
**Two fonts max, one palette.** One heading font + one body font (e.g. Georgia + Calibri) — a third *display* face is fine only for big numerals or the cover title, as long as that heading+body pair stays intact. One dominant brand color (60–70% weight) + one supporting + one accent. Never mix 4+ colors in body content. **The palettes and font pairings in Design Principles are a floor, not a menu:** if the user gave brand colors/fonts or an existing template, match those first; otherwise the named sets are calibrated seeds — blend or diverge freely, as long as the result isn't *worse* than them and still clears the contrast floor.
**Every slide carries a non-text visual — one that informs.** Shape, chart, icon, gradient band that carries meaning, not decoration. A bullet-only deck is interchangeable with a Word doc. Exceptions: literal quote slides, code blocks, a single summary-table slide.
**Less is more — every element earns its place.** The visual rule above guards against bullet-walls; it is not licence to clutter. Don't pad with decorative stats, icons, or filler sections that don't inform ("data slop"). If a slide feels empty, fix it with layout and whitespace, not invented content — cut scope rather than bulk it up, and flag a larger addition instead of making it unprompted.
**Speaker notes on every content slide.** `--type notes --prop text="..."`. The speaker needs a script; the audience shouldn't read the slide verbatim.
**Copy reads human, not AI.** Titles orient on content, not punchline. No "It's not X. It's Y.", no manufactured tension, no faux-insight ("The magic moment"), no one-word drama ("Momentum."). Cut hype adjectives (seamless, robust, game-changing) — let the number carry it.
**Preserve existing templates.** When a file already has a theme and masters, match them. Existing conventions override these guidelines.
### Visual delivery floor (applies to EVERY deck)
Before declaring done, the per-slide render (see QA) MUST satisfy:
- **No placeholder tokens rendered as content.** `{{name}}`, `$fy$24`, `<TODO>`, `lorem`, `xxxx`, empty `()`/`[]` in chart titles never appear.
- **No overflow off-edge, no clipped text in shapes.** `view issues` flags both (`shape_off_slide` + a text-fit hint). To fix a clip: grow the box or shorten the value — never trim content to fit.
- **Cover carries its orienting elements.** Title + subtitle + presenter/client + date + a brand band or key-takeaway strap — a title-only cover reads as a stub. Generous whitespace around them is still right; rich ≠ crowded.
- **Contrast.** `view issues` auto-flags the common case — opaque dark text on a shape's own dark fill (`low_contrast`). It can't see the rest: icon / chart-series fills, scheme/inherited colors, or text over a *separate* background shape. So on any fill with brightness < 30% (`1E2761`, `36454F`, deep forest / berry / cherry), still confirm every body run, card body, chart series, and icon is `FFFFFF` or brightness > 80% — mid-gray (`6B7B8D` ≈ 44%) reads on a laptop and vanishes on projection. Spot-check via `view html` after the dark-fill pass.
If any fails, STOP and fix before declaring done.
## Design Principles
A deck is not a document. The audience has 3 seconds to get each slide. Before adding anything, ask: "If the audience reads only the biggest element and glances once, do they get the point?" If they have to read the bullets, the biggest element is wrong.
### Grid, margins, negative space
Standard widescreen is **33.87 × 19.05cm**. Treat it as a 12-column grid internally:
- **Edge margin ≥ 1.27cm** (0.5") on all sides.
- **Inter-block gap ≥ 0.76cm** (0.3") between cards / columns / rows — pick one value (0.76 or 1.27cm) and use it everywhere; mixed gaps look unfinished.
- **≥ 20% negative space per slide.** Filling every pixel reads as amateur.
- **Compose, don't web-center.** Whitespace is structural: a slide top-weighted with open space in the lower third is correct composition, not an empty defect. Intentional asymmetry (content left, breathing room right) reads more designed than centering everything — don't fill a gap just because it's there.
- For card grids: `usable = 33.87 − 2·margin − (N−1)·gap`, then `col_width = usable / N`. Don't hand-pick x coordinates.
### Font pairings
Pair by document register, not by novelty. "Best For" is a prompt, not a decree; a pairing outside this table is fine if it fits — these 8 are seeds, not the set.
| Header | Body | Best For |
|---|---|---|
| Georgia | Calibri | Formal business, finance, executive reports |
| Arial Black | Arial | Bold marketing, product launches |
| Calibri | Calibri Light | Clean corporate, minimal design |
| Cambria | Calibri | Traditional professional, legal, academic |
| Trebuchet MS | Calibri | Friendly tech, startups, SaaS |
| Impact | Arial | Bold headlines, event decks, keynotes |
| Palatino | Garamond | Elegant editorial, luxury, nonprofit |
| Consolas | Calibri | Developer tools, technical / engineering |
Set both fonts explicitly on every shape (`--prop font=Georgia` on titles, `--prop font=Calibri` on body), not via theme inheritance.
### Color and contrast
The columns: **Primary** (dominant — 60–70% of weight, the color you see first), **Secondary** (supporting tone), **Accent** (sparing, one-hit emphasis), **Text** (body on light fills), **Muted** (captions / axis labels / footer).
| Theme | Primary | Secondary | Accent | Text | Muted |
|---|---|---|---|---|---|
| Coral Energy | `F96167` | `F9E795` | `2F3C7E` | `333333` | `8B7E6A` |
| Midnight Executive | `1E2761` | `CADCFC` | `FFFFFF` | `333333` | `8899BB` |
| Forest & Moss | `2C5F2D` | `97BC62` | `F5F5F5` | `2D2D2D` | `6B8E6B` |
| Charcoal Minimal | `36454F` | `F2F2F2` | `212121` | `333333` | `7A8A94` |
| Warm Terracotta | `B85042` | `E7E8D1` | `A7BEAE` | `3D2B2B` | `8C7B75` |
| Berry & Cream | `6D2E46` | `A26769` | `ECE2D0` | `3D2233` | `8C6B7A` |
| Ocean Gradient | `065A82` | `1C7293` | `21295C` | `2B3A4E` | `6B8FAA` |
| Teal Trust | `028090` | `00A896` | `02C39A` | `2D3B3B` | `5E8C8C` |
| Sage Calm | `84B59F` | `69A297` | `50808E` | `2D3D35` | `7A9488` |
| Cherry Bold | `990011` | `FCF6F5` | `2F3C7E` | `333333` | `8B6B6B` |
Pick by topic, not by default — finance reads Midnight Executive, a product launch reads Coral Energy, safety / LOTO reads Cherry Bold. If the closest named theme is not quite right, blend (e.g. Forest primary + gold `D4A843` accent). Use **Text** on light fills, **Muted** for captions / axis / footer, `FFFFFF` or Secondary for body on dark fills.
On dark backgrounds, text and chart series follow the Hard rules contrast floor above.
### Chart-choice decision table
Wrong chart type kills the 3-second test:
| Data shape | Use | Avoid |
|---|---|---|
| Category comparison (A vs B vs C) | `column` (vertical) / `bar` (≥ 6 categories, horizontal) | pie (slices merge), line (no time axis) |
| Time series, 1–3 series | `line` | area (occlusion), bar (implies discrete) |
| Part-of-whole, 2–5 slices | `pie` / `doughnut` | pie with 8+ slices (unreadable) |
| Correlation / distribution | `scatter` | line (implies ordering) |
| Multiple categories × metrics, dense | stacked `column` or heatmap | one chart per metric — consolidate |
| KPI snapshot (single big number) | **Large-text shape** (60–72pt + ≤ 5-word sublabel), NOT a chart | gauge chart, tiny bar |
Rule of thumb: if > 3 series and > 8 categories, split into two charts or switch to a table.
### Animation
Use as much or as little as the brand and content call for — a formal finance deck trends to near-zero, a product launch can be more expressive. Animation is a tool, not décor. Three floors keep it from hurting the deck (none caps how much you use):
- **Purposeful** — each one reveals or emphasizes (progressive bullet reveal, a build-up chart), never decorates. If it doesn't aid comprehension, cut it.
- **Degrades gracefully** — pptx animation renders inconsistently across viewers (Keynote / Slides / web / mobile) and may not play at all, so every slide must read correctly as a *static* frame. Never hide essential content behind a reveal.
- **Verify live** — animation is runtime-only; `view html` and screenshots can't see it, so confirm in a real presentation viewer before shipping.
Taste steer (not a ban): `fade` / `appear` / a single `zoom-entrance` with snappy durations (~hundreds of ms) fit most decks; `bounce` / `swivel` / `spin` / `fly-from-edge` / dense multi-object choreography usually read amateur — reach for them only when the brand is deliberately playful.
### Layout patterns & data display
Vary layout across slides — repeating the same pattern makes every slide feel identical. These are common building blocks, not the full set — pick one per slide, or build a layout outside the table when the content calls for it:
| Pattern | When to use | Key measurement |
|---|---|---|
| **Two-column** (text left, visual right) | Concept + evidence; feature + screenshot | Each col ≈ 14-15cm; gap 1cm |
| **Icon rows** (icon in filled circle + bold header + description) | Feature lists, benefits, team roles | Icon circle 1.5-2cm; 3-4 rows max |
| **2×2 or 2×3 grid** (card tiles) | Quadrant analysis, SWOT, option comparison | Gap ≥ 0.76cm; consistent card height |
| **Half-bleed image** (full left or right half, content overlay on other side) | Hero moments, case study openers | Image 16-17cm wide; content column ≥ 14cm |
| **Large stat callout** (60-72pt number + ≤5-word sublabel below) | Single KPI, milestone, market size | Use shape, NOT a chart; sublabel 14-16pt muted |
**Data display quick rules:**
- Comparison columns (before/after, A vs B) beat a table for 2-3 options.
- Timelines and process flows: numbered step shapes + connectors, not a bullet list.
### Image treatment (only when a slide uses a photo / screenshot / logo)
**Read the image first** (open the file) and choose treatment from what you see — don't place blind from a filename.
- **Full-bleed photo** → size to COVER the region (crop edges), no border.
- **Screenshot / diagram / logo** → size to FIT (never crop content). A transparent or fit image sits on a contrasting fill — drop a colored rectangle behind it, don't let it float on white.
- **Text over a photo** → never raw on the image. Put it on a card, or lay a protection scrim between image and text (a dark rectangle at ~50–60% opacity, or a gradient fading from the text edge).
- Never stretch (distort the aspect ratio); don't overlay text on a busy screenshot.
- Prefer user-provided images / brand assets; no emoji or self-drawn art unless asked.
### Visual motif commitment
Pick ONE distinctive element (rounded image frames, section numbers in filled circles, single-side border band, diagonal accent strips) and carry it to every slide — commit across the whole deck; styling one slide and leaving the rest plain reads as abandoned. A secondary motif is fine only if it doesn't compete with the primary. Declare it in your build plan first: `## Motif: numbered circles in brand color`.
### Visual AI-tells to avoid
- **No decorative underline under slide titles.** A stripe / rule below a heading is the single most common AI-slide tell — use whitespace or a background-color change instead.
- **No rounded-corner card with a colored left-border accent stripe.** The other classic AI-slide tell — use a solid fill, a top accent band, or whitespace separation instead.
- **No emoji as iconography** unless the brand uses them — use a shape or a real icon asset.
Copy-level tells live in "Copy reads human".
## Common Workflow
1. **Open/save lifecycle.** `officecli open <file>` at the start, `officecli save <file>` at the end to flush your edits to disk. `save` only writes — it leaves the resident warm for any follow-up edit; reach for `officecli close <file>` only when you want to release the resident immediately (a one-shot handoff). Both are always safe — they never error or lose work. Use `batch` for repetitive shape grids. **Flush only at the non-officecli boundary:** officecli's own reads always see your edits; run `save`/`close` only before a non-officecli program reads the file (python-pptx, PowerPoint, a renderer, delivery).
2. **Orient.** New deck: `officecli create "$FILE"`. Existing: `officecli view "$FILE" outline` first. Never edit blind.
3. **Title sequence first (plan, don't build yet).** Before creating any slide or shape, write out the full ordered list of slide titles. If someone reading ONLY the titles can't follow the argument, fix the arc now — cheaper in a list than after 14 slides. Pick ONE title grammar — all topic noun-phrases or all action statements, never a mix — and hold it throughout (see "Copy reads human").
4. **Build in display order.** Add slides in audience-view order: cover → agenda → section-1 divider → section-1 content → section-2 divider → … → closing. `--index` on slide add works, but linear append keeps the build script readable and avoids index-arithmetic bugs. **Before final delivery, confirm slide count + narrative arc match your build plan.** Gate 3's order-sanity check catches cases where the cover ends up as slide 11 of 14 instead of slide 1.
5. **Incremental per slide.** Create slide + background, then title, then supporting shapes / charts / connectors. Always `layout=blank` for custom designs. After each structural op, `get /slide[N] --depth 1` to confirm shape IDs.
6. **Format to spec.** Per the Requirements table; formatting is deliverable, not polish.
7. **Save + verify.** `officecli save` flushes the file to disk (or `officecli close` to flush and also end the session). Always open in the target presentation viewer before shipping — chart colors, animations, fonts, and zoom are runtime features `view html` can't render. Full verification in QA below.
8. **QA — assume there are problems.** Fix-and-verify until a cycle finds zero new issues.
## Quick Start
Minimal viable deck: cover + one content slide + notes. `$FILE` stands in for your filename.
```bash
FILE="deck.pptx"
officecli create "$FILE"
officecli open "$FILE"
# Cover — dark fill, centered title
officecli add "$FILE" / --type slide --prop layout=blank --prop background=1E2761
officecli add "$FILE" /slide[1] --type shape --prop text="FY26 Strategic Review" \
--prop x=2cm --prop y=7cm --prop width=29.87cm --prop height=3cm \
--prop font=Georgia --prop size=44 --prop bold=true --prop color=FFFFFF --prop align=center
# Content — white fill, title + body + notes
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" /slide[2] --type shape --prop text="Revenue grew 18% YoY" \
--prop x=1.5cm --prop y=1.2cm --prop width=30cm --prop height=2cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761
officecli add "$FILE" /slide[2] --type shape --prop text="Enterprise renewals + new EMEA region drove the beat; NRR held at 118%." \
--prop x=1.5cm --prop y=4cm --prop width=30cm --prop height=3cm \
--prop font=Calibri --prop size=20 --prop color=333333
officecli add "$FILE" /slide[2] --type notes --prop text="Lead with the 18% beat, preview EMEA."
officecli save "$FILE"
officecli validate "$FILE"
```
Shape of every build: open → slide+background → title → body → notes → save → validate.
## Reading & Analysis
Start wide, then narrow. `outline` first, `view text` / `get` / `query` once you know where to look.
```bash
officecli view "$FILE" outline # slide count + titles
officecli view "$FILE" annotated # complete per-slide breakdown with fonts, sizes, tables, charts
officecli view "$FILE" text --start 1 --end 5 # text dump (includes table cell text)
officecli view "$FILE" issues # empty slides, overflow hints
officecli view "$FILE" stats # counts + totals (incl. pictures missing alt)
```
**Inspect one element.** XPath-style paths, 1-based. ALWAYS quote. Prefer `@name=` / `@id=` selectors over positional `[N]` (stable across reorderings). `[last()]` works. Add `--json` for machine output.
```bash
officecli get "$FILE" "/slide[1]" --depth 1 # shape list with IDs and names
officecli get "$FILE" "/slide[1]/shape[@name=Title]"
officecli get "$FILE" "/slide[1]/table[1]" --depth 3 # table rows / cells
```
**Query across the deck.** CSS-like selectors; operators `=`, `!=`, `~=`, `>=`, `<=`, `[attr]`, `:contains()`, `:no-alt`. `help pptx query` lists queryable element types.
```bash
officecli query "$FILE" 'shape:contains("Revenue")'
officecli query "$FILE" 'picture:no-alt' # accessibility gap
officecli query "$FILE" 'shape[fill=1E2761]' # color match
officecli query "$FILE" 'shape[width>=10cm]' # numeric
```
**`query --json` output schema.** Results wrap in `.data.results[]` — `jq -r '.data.results[0].format.id'`, NOT `.[0].id`. Shape name is `.name`; fill is `.format.fill`; textColor is `.format.textColor`.
**Visual preview (LEAD).**
```bash
officecli view "$FILE" html # prints an HTML preview path; Read it for per-slide visual audit (best structural ground truth)
officecli view "$FILE" svg --start 3 --end 3 # single slide SVG (charts + gradients do NOT render in SVG)
```
**Reading the output — an expected non-defect:**
- **`layout=blank` has no title placeholder.** Titles are plain `shape` elements, so `view outline` reporting `(untitled)` is **expected**, not a defect. Use `layout=title` + `placeholder[title]` only when screen-reader outline compatibility matters.
## Creating & Editing
Verbs: `add` / `set` / `remove` / `move` / `swap` / `batch` / `raw-set`. Ninety percent of a deck is slides, shapes, text, a few charts, pictures, connectors.
### Slides and backgrounds
A slide is `/slide[N]`. Always pass `layout=blank` for custom designs. Background: solid, gradient, or image.
```bash
officecli add "$FILE" / --type slide --prop layout=blank --prop background=1E2761 # solid
officecli add "$FILE" / --type slide --prop layout=blank --prop "background=1E2761-CADCFC-180" # gradient (start-end-angle)
officecli add "$FILE" / --type slide --prop layout=blank --prop background=image:/path/to/hero.jpg # image background (LEAD)
```
### Shapes
A `shape` holds text, fill, border, position, and optional animation / link.
```bash
officecli add "$FILE" /slide[2] --type shape --prop name=Title --prop text="Key Insight" \
--prop x=2cm --prop y=2cm --prop width=20cm --prop height=3cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761 --prop fill=none
```
Positioning is explicit — no layout engine, you own the grid math. `--prop preset=` picks geometry (`rect`, `roundRect`, `ellipse`, `triangle`, `arrow`, `star5`, ...); custom `M...Z` paths are not supported — pick a preset. **Name shapes at creation** (`--prop name=HeroTitle`) and address later with `"/slide[N]/shape[@name=HeroTitle]"` — names survive z-order / remove-then-add, whereas positional `/shape[3]` (and even `@id=`) shift. Re-`get --depth 1` after any structural change before using positional indexes.
### Text inside shapes (paragraphs, runs, styling)
A shape has paragraphs (`paragraph[K]`) and runs (`run[K]`). For one-line text, `--prop text=` on the shape is enough; a `\n` in the text makes a paragraph break, `\t` a tab (see Shell & Execution Discipline; double `\\n` for a literal). `add --type paragraph` takes the same style props as a shape (text, align, bold, italic, size, color, font). For mixed styling *within* a line, append a styled run:
```bash
officecli add "$FILE" "/slide[2]/shape[@name=Card1]/paragraph[1]" --type run \
--prop text=" (inline detail)" --prop size=14 --prop italic=true --prop color=8899BB
```
### Charts
Pick chart type per the Design Principles chart-choice table. Full prop list (chartType enum, `seriesN.*`, `data=`/`categories=`, axis options): `help pptx add chart`. Typical multi-series with brand colors:
```bash
officecli add "$FILE" /slide[3] --type chart --prop chartType=column \
--prop series1.name=Revenue --prop series1.values="42,45,48" --prop series1.color=1E2761 \
--prop series2.name=Growth --prop series2.values="2,7,7" --prop series2.color=CADCFC \
--prop categories="Q1,Q2,Q3" \
--prop x=2cm --prop y=4cm --prop width=20cm --prop height=10cm
```
Gotchas: (1) chart titles with `()`, `[]`, `TBD` ship as literal text. (2) some viewers normalize chart colors to theme defaults — verify in the target viewer. Series can be added after creation (`add --type series`).
### Pictures
```bash
officecli add "$FILE" /slide[4] --type picture --prop src=hero.jpg \
--prop x=1cm --prop y=1cm --prop width=32cm --prop height=18cm \
--prop alt="Product hero, gradient lit from right"
```
Confirm with `officecli query "$FILE" 'picture:no-alt'` — must be empty before delivery.
### Connectors (LEAD — flowcharts / decision trees first-class)
Draws a line between two shapes or free coordinates. Full prop / enum reference (`shape`, `headEnd`/`tailEnd` values, `from`/`to` ref forms): `help pptx add connector`.
```bash
officecli add "$FILE" /slide[5] --type connector \
--prop "from=/slide[5]/shape[@name=BoxA]" --prop "to=/slide[5]/shape[@name=BoxB]" \
--prop shape=elbow --prop color=333333 --prop tailEnd=triangle
```
**Every flow connector needs an arrowhead.** Without one, `bentConnector3` renders as a directionless line. `preset=rightArrow` overlay only works for horizontal flows; diamonds / decision trees with diverging edges need `tailEnd=`.
### Animations (LEAD)
Use per the Animation floors above (purposeful, degrades gracefully, verify live). Preset names + duration syntax: `help pptx animation`.
```bash
officecli set "$FILE" "/slide[2]/shape[@name=HeroCard]" --prop animation=fade-entrance-400
officecli set "$FILE" "/slide[2]/shape[@name=HeroCard]" --prop animation=none # clear all
```
### Hyperlinks, tooltips, slide-jump
`--prop link=slide[N]` for an in-deck jump (1-based; target slide must exist), `link=nextslide` / `firstslide` / `lastslide` / `previousslide` / `endshow` for named navigation, `link=https://...` for a URL, `--prop tooltip="..."` for hover text.
### Tables, placeholders, groups, zoom — one-liners
- **Tables** — `--type table --prop rows=N --prop cols=M`. Row-level `set` supports `height` and `c1/c2/c3` (seed cell text). Header-row styling is table-level (`firstRow=true` / `headerFill=`), not a row prop. Cell formatting lives on the cell paragraph / run. Populate rows BEFORE setting table-level font (font cascade gets reset by row ops).
- **Placeholders** — `"/slide[N]/placeholder[title]"` / `placeholder[body]`. Available only when the slide uses a layout with placeholders (not `layout=blank`).
- **Groups** (LEAD) — address children via `"/slide[N]/group[@name=G]/shape[1]"`. Survives reordering better than positional indexes.
- **Zoom slide** (LEAD) — `--type zoom --prop target=N` (one link per target; alias `slide`). Emit N separate zoom shapes for a multi-target nav hub. Zoom is a runtime feature — `view html` shows the static geometry; the zoom interaction runs only in a live presentation viewer.
- **Slide comments** — reviewer annotations anchored at `/slide[N]/comment[M]`. Full lifecycle (`add / set / get / query / remove`). Props: `text`, `author`, `initials` (auto-derived), `date` (ISO 8601, defaults to UtcNow), `x` / `y` (length anchor).
```bash
officecli add "$FILE" "/slide[2]" --type comment --prop author="Alice" --prop text="Tighten this bullet" --prop x=20cm --prop y=3cm
officecli query "$FILE" 'comment' --json | jq '.data.results | length' # count all review comments
officecli remove "$FILE" "/slide[2]/comment[1]" # resolve after addressing
```
### Deck-level recipes
Patterns not obvious from the primitives. Each gives the **visual outcome** first, then a runnable block. `$FILE` = your filename. Use `/slide[last()]` to address the slide you just added. The recipes demonstrate **structure and coordinate math** — swap in the palette / fonts you chose for this topic; the navy `1E2761` + Georgia is just the example's theme, not a house style to copy verbatim.
**Z-order.** Later-added shapes are on top. Add background decoration FIRST, titles LAST. To fix after the fact: `--prop zorder=back/front` (renumbers siblings — re-`get --depth 1` before stacking more).
#### (a) Cover (and section divider)
**Visual outcome.** Dark navy fill, centered 44pt title, 18pt ice-blue meta line.
```bash
officecli add "$FILE" / --type slide --prop layout=blank --prop background=1E2761
officecli add "$FILE" "/slide[last()]" --type shape --prop text="Strategic Growth Review" \
--prop x=2cm --prop y=7cm --prop width=29.87cm --prop height=3cm \
--prop font=Georgia --prop size=44 --prop bold=true --prop color=FFFFFF --prop align=center
officecli add "$FILE" "/slide[last()]" --type shape --prop text="Prepared for Acme Leadership — FY26 Outlook" \
--prop x=2cm --prop y=11cm --prop width=29.87cm --prop height=1.2cm \
--prop font=Calibri --prop size=18 --prop color=CADCFC --prop align=center
```
**Section divider** = same cover, plus a giant translucent number (`size=120`, `opacity=0.15`) added FIRST so it sits behind the section title.
#### (b) Data slide (chart + commentary block)
**Visual outcome.** Left two-thirds: column chart with brand series colors. Right one-third: "Key Insight" card with 20pt heading + 18pt body — audience reads the takeaway before parsing the bars.
```bash
officecli add "$FILE" / --type slide --prop layout=blank --prop background=FFFFFF
officecli add "$FILE" "/slide[last()]" --type shape --prop text="FY26 Revenue Beat Plan by 18%" \
--prop x=1.5cm --prop y=1cm --prop width=30cm --prop height=1.8cm \
--prop font=Georgia --prop size=36 --prop bold=true --prop color=1E2761
# Chart — left 2/3 (single-quote the title because of `$`)
officecli add "$FILE" "/slide[last()]" --type chart --prop chartType=column \
--prop series1.name=Actual --prop series1.values="42,45,48,55" --prop series1.color=1E2761 \
--prop series2.name=Plan --prop series2.values="40,42,45,48" --prop series2.color=CADCFC \
--prop categories="Q1,Q2,Q3,Q4" --prop x=1.5cm --prop y=3.5cm --prop width=20cm --prop height=14cm --prop title='FY26 Revenue ($M)'
# Commentary card — right 1/3: background + heading + body
officecli add "$FILE" "/slide[last()]" --type shape --prop preset=roundRect --prop fill=F5F7FA --prop line=none \
--prop x=22.5cm --prop y=3.5cm --prop width=9.8cm --prop height=14cm
officecli add "$FILE" "/slide[last()]" --type shape --prop text="Key Insight" \
--prop x=23cm --prop y=4cm --prop width=9cm --prop height=1.2cm \
--prop font=Georgia --prop size=20 --prop bold=true --prop color=1E2761
officecli add "$FILE" "/slide[last()]" --type shape --prop text="EMEA launch + NRR at 118% drove 12pp of the 18pp beat." \
--prop x=23cm --prop y=5.5cm --prop width=9cm --prop height=11cm \
--prop font=Calibri --prop size=18 --prop color=333333
```
#### (c) Flowchart / process diagram (boxes + connectors)
**Visual outcome.** Four rounded boxes across at y=8cm, each 6×3cm, alternating navy/iceblue, joined by elbow connectors with triangle arrowheads.
Grid math (4 boxes, 33.87cm slide, 1.5cm margins): `gap = (33.87 − 3 − 24) / 3 = 2.29cm`. x-positions: `1.5, 9.79, 18.08, 26.37`.
Each box carries its own label via `valign=middle` (no separate overlay shape needed). Use `batch` heredoc for portable coordinate arithmetic — no `bc`, no bash arrays.
```bash
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Step1","preset":"roundRect","fill":"1E2761","line":"none","x":"1.5cm","y":"8cm","width":"6cm","height":"3cm","text":"Step 1","font":"Georgia","size":"20","bold":"true","color":"FFFFFF","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Step2","preset":"roundRect","fill":"CADCFC","line":"none","x":"9.79cm","y":"8cm","width":"6cm","height":"3cm","text":"Step 2","font":"Georgia","size":"20","bold":"true","color":"1E2761","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Step3","preset":"roundRect","fill":"1E2761","line":"none","x":"18.08cm","y":"8cm","width":"6cm","height":"3cm","text":"Step 3","font":"Georgia","size":"20","bold":"true","color":"FFFFFF","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Step4","preset":"roundRect","fill":"CADCFC","line":"none","x":"26.37cm","y":"8cm","width":"6cm","height":"3cm","text":"Step 4","font":"Georgia","size":"20","bold":"true","color":"1E2761","align":"center","valign":"middle"}}
]
EOF
# Connector pattern — reuse for any box-to-box graph.
for pair in "Step1 Step2" "Step2 Step3" "Step3 Step4"; do
A=${pair% *}; B=${pair#* }
officecli add "$FILE" "/slide[$SLIDE]" --type connector \
--prop "from=/slide[$SLIDE]/shape[@name=$A]" \
--prop "to=/slide[$SLIDE]/shape[@name=$B]" \
--prop shape=elbow --prop color=333333 --prop tailEnd=triangle
done
```
`shape=elbow` is canonical (`bentConnector2` / `bentConnector3` also accepted).
#### (d) Multi-slide deck skeletons
No code block — it's a rhythm. The sequences below are **illustrations of one working cadence (alternating dark dividers with white content), not required running orders** — derive your actual arc from the content first (see "Title sequence first"), then borrow whatever divider/content rhythm fits:
- **10-slide review:** Cover · Agenda · 3 KPI · Div01 · Chart · Chart · Div02 · Flow · Timeline · Close
- **20-slide pitch:** same rhythm × 2, sectioned Problem · Solution · Market · Product · Traction · Model · Team · Financials · Ask
- Every divider must appear **before** its section content (Gate 3 order sanity)
- Cover/divider = (a); chart pages = (b); process pages = (c); KPI pages = (e); decision pages = (f)
#### (e) KPI callouts — giant-number card grid
**Visual outcome.** Three or four giant numbers across a row; each card = unit sublabel + small percent-change chip + one-line takeaway. The single most common exec-deck element.
**Sizing rule.** 60pt Georgia bold fits ~5 chars in a 9.78cm card (`$84.2`, `118%`, `24.5`). For longer values (`$84.2M`), split: `$84.2` as the big number, `USD millions` as the sublabel — never shrink the font to chase a unit suffix, it just wraps.
Grid math (3 cards, 1.5cm margins, 0.76cm gap): `col_width = (33.87 − 3 − 1.52) / 3 = 9.78cm`. x-positions: `1.5, 12.04, 22.58`. Use accent color on a single "watch" card so risk reads in one second.
```bash
# Two cards: navy standard + terracotta watch. Each = bg + big number + sublabel + chip.
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"preset":"roundRect","fill":"1E2761","line":"none","x":"1.5cm","y":"4cm","width":"9.78cm","height":"7cm"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"84.2","x":"1.5cm","y":"4.8cm","width":"9.78cm","height":"2.8cm","font":"Georgia","size":"60","bold":"true","color":"FFFFFF","align":"center"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"USD millions · ARR","x":"1.5cm","y":"8cm","width":"9.78cm","height":"0.8cm","font":"Calibri","size":"14","color":"CADCFC","align":"center"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"+24% YoY","x":"1.5cm","y":"9cm","width":"9.78cm","height":"0.8cm","font":"Calibri","size":"14","bold":"true","color":"CADCFC","align":"center"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"preset":"roundRect","fill":"B85042","line":"none","x":"22.58cm","y":"4cm","width":"9.78cm","height":"7cm"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"\$1.42","x":"22.58cm","y":"4.8cm","width":"9.78cm","height":"2.8cm","font":"Georgia","size":"60","bold":"true","color":"FFFFFF","align":"center"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"CAC payback (yrs)","x":"22.58cm","y":"8cm","width":"9.78cm","height":"0.8cm","font":"Calibri","size":"14","color":"FFFFFF","align":"center"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"text":"+8% — watch","x":"22.58cm","y":"9cm","width":"9.78cm","height":"0.8cm","font":"Calibri","size":"14","bold":"true","color":"FFFFFF","align":"center"}}
]
EOF
```
#### (f) Decision tree — YES/NO branching
**Visual outcome.** Diamond at top-center; YES/NO child boxes diverging left-right; both converge into a shared terminal box. Layout: diamond at `x=13.94, y=2cm, 6×3cm`; YES at `3cm, 7.5cm`; NO at `22.87cm, 7.5cm`; terminal at `13.94cm, 13cm`. Convention: red = stop/escalate, blue = standard, green = safe terminal. **Every connector needs an arrowhead** — readers misparse direction otherwise.
```bash
cat <<EOF | officecli batch "$FILE"
[
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Decide","preset":"diamond","fill":"1E2761","line":"none","x":"13.94cm","y":"2cm","width":"6cm","height":"3cm","text":"Hazardous energy present?","font":"Calibri","size":"14","bold":"true","color":"FFFFFF","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"YesBox","preset":"roundRect","fill":"B85042","line":"none","x":"3cm","y":"7.5cm","width":"8cm","height":"3cm","text":"Lockout + Tagout + Verify","font":"Calibri","size":"16","bold":"true","color":"FFFFFF","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"NoBox","preset":"roundRect","fill":"CADCFC","line":"none","x":"22.87cm","y":"7.5cm","width":"8cm","height":"3cm","text":"Proceed with standard PPE","font":"Calibri","size":"16","bold":"true","color":"1E2761","align":"center","valign":"middle"}},
{"command":"add","parent":"/slide[$SLIDE]","type":"shape","props":{"name":"Done","preset":"roundRect","fill":"2C5F2D","line":"none","x":"13.94cm","y":"13cm","width":"6cm","height":"2.5cm","text":"Begin service","font":"Calibri","size":"16","bold":"true","color":"FFFFFF","align":"center","valign":"middle"}}
]
EOF
```
Then 4 connectors (`Decide→YesBox`, `Decide→NoBox`, `YesBox→Done`, `NoBox→Done`) using the connector loop pattern from (c).
## QA (Required)
**Assume there are problems.** First render is almost never correct. If you found zero issues, you were not looking hard enough.
### Delivery Gate (any failure = REJECT, do NOT deliver)
Gates 1–2b are text/schema-level (cannot see a rendered slide); Gate 3 is the only visual check. Done = every gate PASS **and** Gate 3 loop converged.
Each gate is **run a command, judge its output** — the officecli commands are identical on every OS (macOS / Linux / Windows), so no shell scripting is needed; the judging is yours.
- **Gate 1 — schema.** `officecli validate "<file>"`. Any schema error → REJECT and fix.
- **Gate 2 — overflow / format / structure.** `officecli view "<file>" issues`. If it lists *any* issue (lines tagged `[O1]`, `[C1]`, `[S1]`, …) → REJECT, fix, re-run until clean.
- **Gate 2b — leftover placeholders.** `officecli view "<file>" text`, then scan the output for `xxxx`, `lorem` / `ipsum`, `<TODO>`, `placeholder`, "this slide layout", or empty `()` / `[]`. Any hit → REJECT.
### Gate 3 — Visual audit (MANDATORY)
Pick **one** path:
**Screenshot (default)** — for vision-capable agents. Screenshot each slide in turn — `officecli view "<file>" screenshot --page 1 -o slide1.png`, then `--page 2`, … — until the page index runs past the deck (one screenshot = one slide). If it errors on page 1, use the fallback below.
**Judge every PNG against the checklist, adversarially** — "assume problems exist; finding none means you didn't look hard enough." Report one `slide N: <issue>` line per problem, or `PASS`. This step is required however you run it. **If** your harness can spawn a subagent, delegate the judging to a *fresh, independent* one — the agent that built the deck is biased toward "looks fine", a separate pair of eyes is more critical — handing it the screenshots + this checklist and the same adversarial framing. No subagent? Do exactly the same yourself.
**Fallback — HTML-text** (no vision, or screenshot failed): read `view "$FILE" html` as text. DOM cannot prove **dark-on-dark / fine overlap / arrowheads / gap-margin metrics / column alignment** — flag these as "not visually verified" rather than PASS.
**Optional `--grid N`** — only on user request for layout-rhythm, or when `view outline` shows anomalous layout distribution: `officecli view "<file>" screenshot --grid 3 -o grid.png`.
**Per-slide checklist (assume issues exist):**
- **overlap** — shapes / charts / giant decorative numbers (01/02/03 100pt+) colliding
- **text overflow** — clipped at slide or shape boundary (KPI cards, narrow boxes)
- **narrow text box** — content fits technically but wraps to many short lines (1–2 words each); long sublabel in a 3cm KPI card, body line in a too-tight column
- **dark-on-dark** — fill brightness < 30% with text/icon brightness < 80% (incl. dark icons on dark without a contrasting circle)
- **image treatment** — photo stretched/distorted, text raw on a busy image (no card/scrim), screenshot or logo cropped, transparent image floating on white
- **missing arrowheads** — flowchart connectors as plain lines
- **decorative-line / title mismatch** — accent bar sized for one-line title but title wrapped to two (or vice versa)
- **footer / citation collision** — source line, page number, or footnote touching content above
- **tight margin / gap** — element within ~0.5" of slide edge, or two cards within ~0.3"
- **uneven gaps** — large empty area on one side, cramped on another (broken rhythm)
- **column / repeat-element misalignment** — KPI cards / icons off baseline or inconsistent width
- **order sanity** — sequence matches narrative (cover → agenda → dividers-before-sections → closing)
REJECT with `slide N: <issue>` lines, else "Gate 3 PASS" (HTML-text fallback adds "<unverified-items> not visually verified").
**Fix-verify (mandatory, max 3 cycles).** Fix → re-run Gate 3 → repeat until zero new issues; one fix often surfaces another. After 3 rounds without convergence, **stop** — likely seesaw, template-level cause, or agent misread. Report `slide N: <issue> — attempted: <fixes> — likely root: <template|design-conflict|ambiguous>` and let the user decide.
**Then flush (part of the gate).** Once Gate 3 converges, end with `officecli save "<file>"` — this guarantees your edits are written to disk before delivery (use `officecli close "<file>"` instead to also release the resident on a one-shot handoff). Required final step, not optional. Always safe: never errors or loses work.
## Common Pitfalls
Sanity-check cheatsheet — what breaks on the first try. Design + shell traps.
| Pitfall | Correct approach |
|---|---|
| Unquoted `[N]` in zsh/bash | Always quote paths: `"/slide[1]"`. zsh globs unquoted `[1]` → `no matches found` — #1 first-use stumble |
| `--name "foo"` | All attributes go through `--prop`: `--prop name="foo"` |
| `/shape[myname]` (bare name in brackets) | Use `@name=` selector: `/shape[@name=myname]` or `/shape[@id=10007]` |
| Paths 1-based vs `--index` 0-based | `/slide[1]` = first slide; `--index 0` = first position |
| `$` in `--prop text=` | Single-quote: `--prop text='$15M'`. Double-quoted `"$15M"` gets shell-expanded to `M` |
| `\n` / `\t` in `--prop text=` | Interpreted by the CLI: `\n` = paragraph break, `\t` = tab. Double `\\n` for a literal |
+676
View File
@@ -0,0 +1,676 @@
---
name: officecli-word-form
description: "Use this skill to create fillable Word forms (.docx) with real Content Controls (SDT) + legacy FormField checkboxes + MERGEFIELD mail-merge placeholders + document protection. Trigger on: 'fillable form', 'form fields', 'content controls', 'SDT', 'word form', 'fill in', 'only editable fields', 'protect document', 'onboarding form', 'HR intake', 'survey template', 'contract / SOW template', 'mail-merge template', 'compliance checklist', 'medical intake questionnaire'. Output is a single .docx where specific fields are editable and the rest is locked. This skill is INDEPENDENT, not a scene layer on docx — payload is `w:sdt` + `w:ffData` + `w:fldChar` + `documentProtection`, none of which docx base skill covers. Do NOT trigger for regular reports, letters, memos, academic papers, pitch decks, or any document with no user-fillable fields — route those to officecli-docx or its scene layers."
---
# OfficeCLI Word-Form Skill
**This skill is INDEPENDENT, not a scene layer on docx.** A form's payload — `<w:sdt>` controls, `<w:ffData>` legacy fields, `<w:fldChar>` mail-merge, `documentProtection` — is a distinct element class from docx's paragraph/heading/style primitives. Its QA is different too: docx's Delivery Gate cares about visual layout and live PAGE fields, this skill's cares about data plumbing (protection enforced / alias+tag / items injected / name ≤ 20 / no underscore anti-pattern). **Reverse handoff:** if the user's document has no fillable fields (report, letter, memo, thesis, proposal), route to `officecli-docx` or a docx scene skill — don't use this one.
## BEFORE YOU START (CRITICAL)
**If `officecli` is not installed:**
`macOS / Linux`
```bash
if ! command -v officecli >/dev/null 2>&1; then
curl -fsSL https://d.officecli.ai/install.sh | bash
fi
```
`Windows (PowerShell)`
```powershell
if (-not (Get-Command officecli -ErrorAction SilentlyContinue)) {
irm https://d.officecli.ai/install.ps1 | iex
}
```
Verify: `officecli --version`
If `officecli` is still not found after first install, open a new terminal and run the verify command again.
If the install command above fails (e.g. blocked by security policy, no network access, or insufficient permissions), install manually — download the binary for your platform from https://github.com/iOfficeAI/OfficeCLI/releases — then re-run the verify command.
## Help-First Rule
This skill teaches what a real form needs, not every CLI flag. When a prop / alias / enum is uncertain, consult help BEFORE guessing: `officecli help docx [element] [--json]` (e.g. `sdt`, `formfield`, `field`). Help is pinned to the installed CLI version and is authoritative — when this skill and help disagree, **help wins** (the prop set on `sdt` in particular has grown over time; trust `help docx sdt`, not a hardcoded list).
## Mental Model & Inheritance
A Word form is a `.docx` plus four OpenXML payload layers plain-docx skills do not touch: **`<w:sdt>`** content controls (types: text / richtext / dropdown / combobox / date / picture / group), **`<w:ffData>`** legacy FormField (still the only way to get a real checkbox — SDT `type=checkbox` is not implemented), **`<w:fldChar>`** complex fields (MERGEFIELD, REF, PAGEREF, SEQ, IF — template-time, not user-fill), and **`documentProtection`** (the lock that makes non-field text read-only in Word — and, on the CLI, `protection=forms` locks non-field content edits (those need `--force` or `raw-set`) but still allows form-field (SDT) edits, which is the point of forms protection).
**No inheritance from docx v2.** docx's Delivery Gate (cover-fill %, live-PAGE check) does NOT apply — form QA is `view forms` + `query sdt alias+tag` + `protectionEnforced`.
**Reverse handoff to docx.** Route back to `officecli-docx` for reports / letters / memos / thesis / pitch decks / any document with no editable fields. Use **this** skill when the document's purpose is data capture or template merge.
## Shell & Execution Discipline
**One command at a time. Read output before the next.** OfficeCLI is incremental — every `add` / `set` / `remove` immediately mutates the file. All recipes below use `FILE=form.docx` as a shell variable.
**Three shell-escape layers:**
1. **Quote every path with `[N]`** — zsh/bash glob-expand brackets. `officecli get "$FILE" /body/sdt[1]` fails with `no matches found`. Correct: `officecli get "$FILE" '/body/sdt[1]'`.
2. **Single-quote any prop containing `$`** — `"Total: $50,000"` becomes `"Total: ,000"` after `$50` variable expansion. Correct: `'Total: $50,000'`.
3. **`--after find:<text>` uses outer single quotes, never inner double quotes** — `--after find:"Client Signature:"` makes the quotes part of the search string; match fails. Correct: `--after 'find:Client Signature:'`.
**`WARNING: UNSUPPORTED` (exit 2) is a silently-wrong element.** The CLI created the element *without* the rejected prop (exit 2 = ran with caveats; only when *every* prop is refused does `--json` report `success: false`). Any UNSUPPORTED in your build log means a prop name the current CLI does not accept on that element — stop, check `help docx <element>` for the right prop name (most SDT props such as `items`/`format`/`lock` ARE accepted now; `maxlength` is not), fix the command, and re-run. Do not ship on top.
**`protection=forms` is the LAST structural command.** Once it is set, `protection=forms` locks non-field content (those edits need `--force` or `raw-set`) but still allows form-field (SDT) edits — which is the point of forms protection. A non-field content edit (e.g. `set /body/p[N] --prop text=`) is refused (`ERROR: Document is protected … use --force`); a form-field edit (e.g. `set /body/sdt[N] --prop text=` / `--prop alias=`) succeeds (exit 0). Use `Query("editable")` to find fillable fields. So finish all non-field (static layout) edits first, then lock; if you must edit static content afterward, pass `--force` (or temporarily clear protection, edit, re-lock).
### `--after find:` micro-playbook
`--after find:<text>` matches the **first** occurrence. Bad anchor = wrong insertion location, expensive to debug. Three rules:
1. **Anchor must be globally unique.** In bilingual contracts "甲方签字" matches both parties — use a unique phrase like "甲方签字(Service Provider)" or full English title.
2. **After insert, `/body/p[last()]` is unreliable** — the find insertion changes `<w:body>` child order. To continue operating on the new paragraph, read its real paraId: `officecli query "$FILE" paragraph --json | jq -r '.data.results[-1].format.paraId'`.
3. **Chinese + full-width parens `()`** match literally in `find`, but when unsure, `officecli view "$FILE" text | grep -n "锚点"` first to confirm the exact bytes in the file.
```bash
# Trap: first-match hits 甲方 only, 乙方 missed
officecli add "$FILE" /body --type sdt --after 'find:签字'
# Fix: two signatories, two unique anchors
officecli add "$FILE" /body --type sdt --prop alias=Party_A_Name --prop tag=party_a \
--after 'find:甲方签字(Service Provider)'
PID_A=$(officecli query "$FILE" paragraph --json | jq -r '.data.results[-1].format.paraId')
officecli add "$FILE" "/body/p[@paraId='$PID_A']" --type sdt --prop alias=Party_A_Title --prop tag=party_a_title
```
Inline SDT via `--after find:` is added as a child of the matched paragraph, not as a new paragraph — use this when label + SDT must share a line.
## What makes a real form (identity)
A real fillable form requires **structured fields** + **document protection**.
| Approach | Word user sees | CLI-readable | Real form? |
|---|---|---|---|
| SDT controls + `protection=forms` | Gray-bordered fields; rest locked | `query sdt` / `view forms` | **YES** |
| FormField checkbox + `protection=forms` | Real clickable checkbox; rest locked | `query formfield` / `view forms` | **YES** (checkbox only) |
| MERGEFIELD placeholders | `«CustomerName»` merged by downstream engine | `query field` | **YES** (template-time) |
| Underscores `___` / blank lines | Visual-only; whole doc editable | No — no structured fields | **NO** |
**Do not simulate fields with underscores.** `姓名:_______________` produces zero structured data and leaks past every verification. Always use `--type sdt` or `--type formfield`.
**Checkbox is formfield, NOT SDT.** `--type sdt --prop type=checkbox` exits 1 (`SDT type 'checkbox' is not implemented`). Every checkbox in every recipe uses `--type formfield --prop type=checkbox`.
**MERGEFIELD is a separate track.** `view forms` lists SDT + formfield only; `query field` lists complex fields only. Two disjoint inventories; both valid in one file.
## Requirements for Outputs (hard floor)
Every form must satisfy these — Delivery Gate enforces each as an executable check.
1. `protection=forms` enforced (`get $FILE /` → `protectionEnforced=True`).
2. Every SDT has both `alias` + `tag`.
3. Every dropdown/combobox has non-empty `items=...` in `view forms`.
4. Every date SDT shows the intended `format=...`.
5. Every locked SDT shows `lock=sdtLocked` / `contentLocked` / `sdtContentLocked` as intended.
6. Zero `WARNING: UNSUPPORTED` in build log.
7. Zero `type=checkbox` on any SDT.
8. Every formfield `name` ≤ 20 characters.
9. Zero underscore-line / blank-line placeholders.
10. Field types match user intent (short text / paragraph / fixed list / list+custom / date / boolean).
## Three Paths (core decision)
SDT props are **first-class on `add`** — confirm the current set with `officecli help docx sdt`. Today that includes `type, tag, alias, text, items, format, lock, placeholder/placeholderText, date.*` and the SDT types `text / richtext / dropdown / combobox / date / group / picture`. So most forms are pure `add` — no raw-set, no Word template. Two paths remain only for the genuinely-unreachable cases. **Pick the path before writing a single command.**
### Path A — Pure CLI (the default for almost everything)
**Use when**: any text / richtext / dropdown / combobox / date / picture / group SDT — including its options, date format, and lock. Pass the props straight on `add`; verify each persists with `get '/body/sdt[N]' --json`.
```bash
# dropdown WITH its options and a non-default date format, in one add each — no raw-set:
officecli add "$FILE" /body --type sdt \
--prop type=dropdown --prop alias="Department" --prop tag=dept \
--prop items="Engineering,Finance,HR"
officecli add "$FILE" /body --type sdt \
--prop type=date --prop alias="Start Date" --prop tag=start \
--prop format="yyyy年MM月dd日"
# lock works on add AND set:
officecli add "$FILE" /body --type sdt --prop type=text --prop tag=full_name \
--prop alias="Full Name" --prop text="Enter full name" --prop lock=sdtLocked
# protection comes last, once all fields exist:
# officecli set "$FILE" / --prop protection=forms
```
### Path B — CLI + `raw-set` bridge (only an attribute help does NOT expose)
**Use when**: you need an SDT attribute that `help docx sdt` does not list (e.g. a `<w:listItem>` whose `w:value` must differ from its display text, or a sdtPr child with no `--prop`). `raw-set` is OfficeCLI's universal OpenXML fallback — `officecli --help` lists it as a top-level command. For ordinary dropdowns use `--prop items=` (Path A); raw-set here is the exception, not the rule.
```bash
# Display text ≠ stored value — not expressible via items=, so inject the listItems directly:
officecli add "$FILE" /body --type sdt --prop type=dropdown --prop alias="Department" --prop tag=dept
officecli raw-set "$FILE" /document \
--xpath "//w:sdt[w:sdtPr/w:tag/@w:val='dept']/w:sdtPr/w:dropDownList" \
--action append \
--xml '<w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Engineering" w:value="ENG"/><w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Finance" w:value="FIN"/>'
```
### Path C — Word template (only what no API reaches)
**Use when**: a **real SDT checkbox** (`type=checkbox` still exits 1 — use a legacy FormField instead, see §Legacy FormField), a `placeholderDocPart` prompt-text part, or custom richtext appearance / cross-part nesting beyond `--prop` reach. Picture and grouped SDTs are NO LONGER here — they add fine via Path A.
```bash
# One-time in Word: Developer tab → Insert Content Control → Save as template.docx
cp templates/onboarding_with_signature.docx "$FILE"
officecli open "$FILE"
officecli view "$FILE" forms # inspect embedded controls + paths
officecli set "$FILE" '/body/sdt[@sdtId=3]' --prop text="Jane Smith"
officecli set "$FILE" / --prop protection=forms
```
### Decision table
| Need | Path | Note |
|---|---|---|
| text / richtext SDT with default string | **A** | `--prop type/alias/tag/text` |
| text SDT that must be locked | **A** | `--prop lock=sdtLocked` works on `add` (and `set`) |
| dropdown / combobox **with options** | **A** | `--prop items="A,B,C"` |
| date SDT with non-default format | **A** | `--prop format="yyyy年MM月dd日"` |
| signature picture SDT, grouped SDT | **A** | `--prop type=picture` / `type=group` add directly |
| dropdown whose stored value ≠ display text | **B** | raw-set append `<w:listItem w:value=…>` |
| real checkbox | **FormField** | `--type formfield --prop type=checkbox` (see §Legacy FormField) |
| mail-merge placeholder | **MERGEFIELD** | `--type field --prop fieldType=mergefield` (see §MERGEFIELD) |
| real SDT checkbox / placeholder part / custom appearance | **C** | build skeleton in Word, fill via CLI |
## Quick Start — Path A + FormField (minimal intake form)
Two SDT text fields, one checkbox, protection. Paste and adapt; this is the smallest form worth shipping.
```bash
FILE=intake.docx
officecli close "$FILE" 2>/dev/null; rm -f "$FILE" # preflight: clear stale resident / prior file (cold-start after CLI upgrade commonly leaks a resident)
officecli create "$FILE"
officecli open "$FILE"
officecli set "$FILE" / --prop title="Employee Onboarding Intake" \
--prop docDefaults.font="Calibri" --prop docDefaults.fontSize="12pt"
officecli add "$FILE" /body --type paragraph \
--prop text="Employee Onboarding Intake" --prop style=Heading1 \
--prop size=20 --prop bold=true --prop spaceAfter=18pt
officecli add "$FILE" /body --type paragraph \
--prop text="Full Name:" --prop size=11 --prop bold=true --prop spaceAfter=4pt
officecli add "$FILE" /body --type sdt --prop type=text \
--prop alias="Full Name" --prop tag=full_name --prop text="Enter full name"
officecli add "$FILE" /body --type paragraph \
--prop text="Start Date:" --prop size=11 --prop bold=true --prop spaceAfter=4pt
officecli add "$FILE" /body --type sdt --prop type=date \
--prop alias="Start Date" --prop tag=start_date
officecli add "$FILE" /body --type paragraph \
--prop text="Read and agree to employee handbook" --prop size=11 --prop spaceAfter=4pt
officecli add "$FILE" /body --type formfield \
--prop type=checkbox --prop name=agree_handbook --prop checked=false
officecli set "$FILE" '/body/sdt[1]' --prop lock=sdtlocked
officecli set "$FILE" '/body/sdt[2]' --prop lock=sdtlocked
officecli set "$FILE" / --prop protection=forms
officecli close "$FILE"
officecli view "$FILE" forms
```
## Path B — raw-set recipes
Three recipes cover almost every complex-attr need on SDT forms.
### B1 — Dropdown items (append)
```bash
# Skeleton (Path A)
officecli add "$FILE" /body --type sdt --prop type=dropdown \
--prop alias="Department" --prop tag=dept
# Inject items
officecli raw-set "$FILE" /document \
--xpath "//w:sdt[w:sdtPr/w:tag/@w:val='dept']/w:sdtPr/w:dropDownList" \
--action append \
--xml '<w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Engineering" w:value="Engineering"/><w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Finance" w:value="Finance"/><w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="HR" w:value="HR"/>'
# Verify
officecli get "$FILE" '/body/sdt[1]' # expect: type=dropdown items=Engineering,Finance,HR
```
**Template.** Swap `<TAG>` / `<LABEL>` / `<VALUE>` only. `xmlns:w=...` is required on every root `<w:listItem>` — raw-set does not inherit namespace prefixes. Chain multiple `<w:listItem>`s in one call; option order is preserved.
### B2 — Combobox items (same as B1, different xpath tail)
```bash
officecli add "$FILE" /body --type sdt --prop type=combobox \
--prop alias="Current Medication" --prop tag=current_med
officecli raw-set "$FILE" /document \
--xpath "//w:sdt[w:sdtPr/w:tag/@w:val='current_med']/w:sdtPr/w:comboBox" \
--action append \
--xml '<w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Antihypertensives" w:value="Antihypertensives"/><w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Insulin" w:value="Insulin"/><w:listItem xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:displayText="Other (specify)" w:value="Other"/>'
```
Only difference from B1: `w:comboBox` vs `w:dropDownList` in the xpath tail. Combobox lets the user type custom input; dropdown does not.
### B3 — Date format (setattr)
```bash
officecli add "$FILE" /body --type sdt --prop type=date \
--prop alias="Contract Start Date" --prop tag=contract_start
# Chinese: yyyy年MM月dd日
officecli raw-set "$FILE" /document \
--xpath "//w:sdt[w:sdtPr/w:tag/@w:val='contract_start']/w:sdtPr/w:date/w:dateFormat" \
--action setattr \
--xml "w:val=yyyy年MM月dd日"
# US: w:val=MM/dd/yyyy
# ISO: w:val=yyyy-MM-dd (already the default)
# Long: w:val="MMMM d, yyyy"
officecli get "$FILE" '/body/sdt[N]' # expect: type=date format=yyyy年MM月dd日
```
`setattr` replaces one attribute — do not quote the value inside `--xml`. Only `w:val` is touched; the `<w:dateFormat>` wrapper is preserved.
### raw-set actions & errors
| `--action` | Form use |
|---|---|
| `append` | Insert new child at end of target (B1, B2 — listItem) |
| `setattr` | Change one attribute; `--xml "key=value"` (B3 — dateFormat/@val) |
| `replace` | Replace entire target (rare — reset a full `<w:date>` wrapper) |
| `remove` | Delete the target (clear options before re-populate) |
| Symptom | Fix |
|---|---|
| `raw-set: 0 element(s) affected` | XPath did not match. Check the `tag` value and whether the SDT is block or inline. Fall back to `officecli raw $FILE /document` to read the real XML. |
| `Error: prefix 'w' is not defined` | Missing `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"` on the fragment — every root element in `--xml` needs it. |
| Items readback empty after append | `<w:dropDownList/>` must already exist (Path A `type=dropdown` ensures this). If absent, append has nowhere to insert. |
| `VALIDATION: N new error(s) introduced` on same line as success | Your append introduced a schema-invalid child. Treat as stop-and-fix even though `raw-set` exits 0. |
## Path C — Word template workflow
For fields CLI cannot express (signature `picture` SDT, real SDT checkbox, `placeholderDocPart` prompt text, grouped SDTs, custom richtext styling), build the skeleton once in Word, then fill via CLI.
**One-time in Word:** File → Options → Customise Ribbon → Developer. Developer tab → Insert Picture / Check Box / Grouping Content Control → right-click → Properties → set Title (`alias`) + Tag. Save as `template.docx`.
**Fill via CLI:**
```bash
cp templates/onboarding_with_signature.docx "$FILE"
officecli open "$FILE"
officecli view "$FILE" forms # see /body/... paths + sdtId values
officecli set "$FILE" '/body/sdt[@sdtId=3]' --prop text="Jane Smith"
officecli set "$FILE" / --prop protection=forms
officecli close "$FILE"
```
## MERGEFIELD (data-driven track)
`help docx field` declares a `fieldType` enum of ~30 values including `mergefield`, `ref`, `pageref`, `seq`, `if` — all CLI-expressible with their typed props. MERGEFIELD coexists with SDT in the same file but is reported by `query field` only; `view forms` does NOT list MERGEFIELDs (they are not user-fillable).
**Canonical MERGEFIELD:**
```bash
officecli add "$FILE" /body --type paragraph --prop text="Dear "
officecli add "$FILE" '/body/p[1]' --type field --prop fieldType=mergefield --prop name=CustomerName
officecli add "$FILE" '/body/p[1]' --type run --prop text=", "
officecli add "$FILE" '/body/p[1]' --type field --prop fieldType=mergefield --prop name=CompanyName
# Readback: "Dear «CustomerName», «CompanyName»"
```
**Element-type shortcut** (equivalent): `officecli add "$FILE" '/body/p[1]' --type mergefield --prop name=CustomerName`.
### Common field patterns
| Pattern | Call shape |
|---|---|
| Mail-merge placeholder | `--type field --prop fieldType=mergefield --prop name=<FieldName>` |
| Mail-merge with numeric picture (money, percent) | `--type field --prop fieldType=mergefield --prop name=Amount --prop instr='MERGEFIELD Amount \# "#,##0.00"'`. The typed `format` prop is ignored for mergefield (prints a warning) — use `instr` (alias `instruction`) to embed the full field code. Verify: `query "$FILE" field --json \| jq '.data.results[].format.instruction'` must contain `\#` and the picture. |
| Mail-merge with date picture | `--type field --prop fieldType=mergefield --prop name=StartDate --prop instr='MERGEFIELD StartDate \@ "yyyy-MM-dd"'` |
| Cross-reference to bookmark text | `--type field --prop fieldType=ref --prop name=<BookmarkName>` |
| Cross-reference to bookmark's page number | `--type field --prop fieldType=pageref --prop name=<BookmarkName>` |
| Auto-numbering (Figure 1 / 2 / 3) | `--type field --prop fieldType=seq --prop identifier=Figure` |
| Page number in footer | `--type field --prop fieldType=page` |
| "Page X of Y" | two fields: `fieldType=page` + `fieldType=numpages` |
| Conditional text | `--type field --prop fieldType=if --prop expression='{ MERGEFIELD Gender } = "Male"' --prop trueText="Mr." --prop falseText="Ms."` |
### IF conditional (CLI-expressible)
```bash
officecli add "$FILE" /body --type paragraph --prop text=""
officecli add "$FILE" '/body/p[last()]' --type field --prop fieldType=if \
--prop expression='{ MERGEFIELD Gender } = "Male"' \
--prop trueText="Mr." --prop falseText="Ms."
officecli add "$FILE" '/body/p[last()]' --type run --prop text=" "
officecli add "$FILE" '/body/p[last()]' --type field --prop fieldType=mergefield --prop name=LastName
# Merge-time result: "Mr. «LastName»" or "Ms. «LastName»"
```
Nested wrappers like `{ IF { MERGEFIELD X } = "Y" { REF bm } "fallback" }` are not expressible via `--prop` chaining — drop to raw-set a hand-crafted `<w:fldChar>` / `<w:instrText>` fragment, or build once in a Word template (Path C).
**Readback.** `query $FILE field` lists `/field[N]` + instruction + `fieldType`. `view $FILE forms` does NOT list MERGEFIELDs (only SDT + formfield) — they are template-time, not end-user fillable. `get $FILE '/body/p[1]'` renders the guillemet-wrapped field name.
## Legacy FormField
Use FormField **when you need a real checkbox**. For text/dropdown, prefer SDT.
`help docx formfield`: `type` (text/checkbox/check/dropdown), `name` (required, **≤ 20 chars** — OpenXML schema MaxLength; add passes longer but `validate` rejects), `text` (text only, alias `value`), `checked` (checkbox only).
```bash
# CHECKBOX — the only real checkbox (SDT type=checkbox is not implemented)
officecli add "$FILE" /body --type formfield --prop type=checkbox \
--prop name=agree_terms --prop checked=false
# TEXT formfield
officecli add "$FILE" /body --type formfield --prop type=text \
--prop name=emp_name --prop text="Enter name"
# DROPDOWN formfield — items NOT settable via CLI; use Word template or SDT Path B
officecli add "$FILE" /body --type formfield --prop type=dropdown --prop name=dept_select
# Read / modify by name (stable) or 1-based index
officecli get "$FILE" '/formfield[agree_terms]'
officecli set "$FILE" '/formfield[agree_terms]' --prop checked=true
officecli set "$FILE" '/formfield[emp_name]' --prop text="Jane Smith"
officecli set "$FILE" '/formfield[dept_select]' --prop text="Engineering"
```
FormField paths (`/formfield[N]` or `/formfield[<name>]`) are separate from SDT paths (`/body/sdt[N]`). Both coexist; `protection=forms` covers both.
**Scale.** Tested with 50+ checkboxes in a single document — no practical cap on formfield count; build and `validate` remain clean. `name` ≤ 20 chars (K13) is the only hard constraint.
**Renderer note — formfield checkbox `[RENDERER-BUG]`.** LibreOffice's PDF export occasionally renders the formfield checkbox as `☐☐` (doubled box). Word and WPS render a single clickable box (toggles ☑). This is a LibreOffice renderer quirk, **not a skill or document quality issue** — see K19. Do not attempt workarounds in the form; if an evaluator screenshots a LibreOffice-generated PDF and sees `☐☐`, attribute to `[RENDERER-BUG]`.
## Document protection & lock
### Enabling form protection
```bash
officecli set "$FILE" / --prop protection=forms
officecli get "$FILE" / # look for: protectionEnforced=True
```
### Protection modes
| Mode | Word user can | CLI behavior |
|---|---|---|
| `forms` | Fill SDT + formfield only | Form-field (SDT) edits work (exit 0); non-field content edits need `--force` or `raw-set` |
| `readOnly` | Read only | Non-field edits need `--force`; raw-set bypasses |
| `comments` | Add comments only | Non-field edits need `--force`; raw-set bypasses |
| `trackedChanges` | Edit with tracked changes only | Non-field edits need `--force`; raw-set bypasses |
| `none` | Full editing | All ops work |
**KEY:** Document protection restricts Word users AND the CLI. Under `protection=forms`, form-field (SDT) edits — `set /body/sdt[N] --prop text=` / `--prop alias=` — succeed (exit 0); this is the point of forms protection. Only NON-field content edits (e.g. `set /body/p[N] --prop text=`) are refused with `ERROR: Document is protected (mode: forms). … use --force to override`; pass `--force` (or `raw-set`, the only verb that fully bypasses protection) to edit static content anyway. Use `Query("editable")` to find the fields a Word user could still fill.
### Lock values (settable on `add` and `set`)
```bash
officecli set "$FILE" '/body/sdt[1]' --prop lock=sdtlocked # content editable; control cannot be deleted
officecli set "$FILE" '/body/sdt[1]' --prop lock=contentlocked # content read-only; control can be deleted
officecli set "$FILE" '/body/sdt[1]' --prop lock=sdtcontentlocked # both locked
# Omit lock entirely → unlocked (default)
```
`--prop lock=...` works on `add` as well as `set` — set it inline when you create the control. Readback normalises to camelCase (`sdtLocked`) regardless of input case — both accepted.
### lock × `protection=forms` interaction
| lock value | `protection=forms` active | Word user can edit? | Word user can delete control? |
|---|---|---|---|
| (none) | yes | **Yes** | **Yes** |
| `sdtlocked` | yes | Yes | No |
| `contentlocked` | yes | No | Yes |
| `sdtcontentlocked` | yes | No | No |
| block-level SDT wrap `contentlocked` | any | No (wrapped paragraph read-only regardless of protection) | No |
| any | `readOnly` mode | No | No |
### Block-level lock (paragraph-wrapping SDT)
`protection=forms` is document-level — once an admin unprotects, every static paragraph (disclaimer, legal attestation, contract clause) becomes editable again. Master templates need defense-in-depth: wrap the critical paragraph in a block-level `<w:sdt>` with `lock=contentLocked`, so the content stays read-only even after protection is stripped.
```bash
officecli add "$FILE" /body --type paragraph \
--prop text="I authorize the above and acknowledge all clauses." --prop size=11 --prop spaceAfter=12pt
PID=$(officecli query "$FILE" paragraph --json | jq -r '.data.results[-1].format.paraId')
# raw-set actions: append | prepend | insertbefore | insertafter | replace | remove | setattr
# No `wrap` action — two-step instead: (1) insertbefore an empty <w:sdt><w:sdtContent/></w:sdt>,
# (2) move the original <w:p> inside by `replace` on the sdtContent with a copy of the paragraph XML.
# Simpler alternative: read the paragraph XML via `officecli raw`, then `replace` the whole <w:p> with <w:sdt>...<w:sdtContent>[original w:p]</w:sdtContent></w:sdt>:
# NOTE: this needs an XML-aware extraction, NOT awk. `raw /document` emits the whole document on ONE line,
# so an awk range like `/paraId=.../,/<\/w:p>/` grabs the entire <w:document> (schema-invalid). Parse the XML
# and pull the single <w:p> by paraId instead:
PARA_XML=$(officecli raw "$FILE" /document | python3 -c '
import sys, xml.etree.ElementTree as ET
xml = sys.stdin.read(); pid = sys.argv[1]
ns = {"w":"http://schemas.openxmlformats.org/wordprocessingml/2006/main","w14":"http://schemas.microsoft.com/office/word/2010/wordml"}
for p, u in ns.items(): ET.register_namespace(p, u)
root = ET.fromstring(xml); key = "{%s}paraId" % ns["w14"]
for p in root.iter("{%s}p" % ns["w"]):
if p.attrib.get(key) == pid:
sys.stdout.write(ET.tostring(p, encoding="unicode")); break
' "$PID")
officecli raw-set "$FILE" /document \
--xpath "//w:p[@w14:paraId='$PID']" \
--action replace \
--xml "<w:sdt xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\"><w:sdtPr><w:alias w:val=\"Authorization\"/><w:tag w:val=\"auth_para\"/><w:lock w:val=\"contentLocked\"/></w:sdtPr><w:sdtContent>${PARA_XML}</w:sdtContent></w:sdt>"
```
Verify with `query sdt --json | jq '.data.results[] | select(.format.lock == "contentLocked" and .format.type == "block")'`. Use only for legal attestations, compliance disclaimers, confidentiality clauses — regular intake fields do not need this.
### Role-gated fields (multi-role forms)
When one form is filled by two roles (patient vs physician; Party A vs Party B), use `lock=contentLocked` on the fields the other role must not touch. Under `protection=forms`, `contentLocked` SDTs display as read-only in Word; the intended role unprotects (or the admin swaps role-specific copies) to fill the other half.
```bash
# Patient section — editable (no lock, or sdtlocked to prevent accidental deletion only)
officecli set "$FILE" '/body/sdt[1]' --prop lock=sdtlocked # patient_name
officecli set "$FILE" '/body/sdt[2]' --prop lock=sdtlocked # patient_dob
# Physician section — locked against patient edits
officecli set "$FILE" '/body/sdt[14]' --prop lock=contentLocked # physician_diagnosis
officecli set "$FILE" '/body/sdt[15]' --prop lock=contentLocked # physician_signature
```
This is the core pattern for medical intake, two-party contracts, sequential-approval forms.
## Recipe — Contract / SOW template with MERGEFIELD + signature
Row-map across the three sub-recipes: SDT[1]=project_name, SDT[2]=contract_start, SDT[3]=payment_schedule, SDT[4]=signatory_name (inline). Run (sow-a) → (sow-b) → (sow-c) in order on the same `$FILE`; each sub-recipe stays under 20 lines so a shell-escape slip never cascades past one block.
### Recipe (sow-a) Boilerplate + cover + parties
Creates the file, sets docDefaults, writes the title / intro, and drops the two MERGEFIELD placeholders (`CustomerName`, `ContractNo`) that downstream mail-merge will fill.
```bash
FILE=sow.docx
officecli create "$FILE"
officecli open "$FILE"
officecli set "$FILE" / --prop title="Statement of Work" \
--prop docDefaults.font="Calibri" --prop docDefaults.fontSize="12pt"
officecli add "$FILE" /body --type paragraph --prop text="Statement of Work" \
--prop style=Heading1 --prop size=20 --prop bold=true --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph \
--prop text="This Statement of Work ('SOW') is entered into between the parties identified below and governs the delivery of professional services." \
--prop size=11 --prop spaceAfter=12pt
officecli add "$FILE" /body --type paragraph --prop text="Customer: "
officecli add "$FILE" '/body/p[last()]' --type field \
--prop fieldType=mergefield --prop name=CustomerName
officecli add "$FILE" /body --type paragraph --prop text="Contract #: "
officecli add "$FILE" '/body/p[last()]' --type field \
--prop fieldType=mergefield --prop name=ContractNo
```
### Recipe (sow-b) SDT fields — all via Path A
Adds the three block-level SDTs (project / date / dropdown) and the inline signature SDT anchored via `--after 'find:Client Signature:'`. The date format and dropdown items go straight on `add` — no raw-set needed.
```bash
officecli add "$FILE" /body --type sdt --prop type=text \
--prop alias="Project Name" --prop tag=project_name --prop text="Enter project name"
officecli add "$FILE" /body --type sdt --prop type=date \
--prop alias="Contract Start Date" --prop tag=contract_start --prop format="MM/dd/yyyy"
officecli add "$FILE" /body --type sdt --prop type=dropdown \
--prop alias="Payment Schedule" --prop tag=payment_schedule \
--prop items="Full Prepayment,Net 30 Upon Delivery"
officecli add "$FILE" /body --type paragraph --prop text="Client Signature:" \
--prop bold=true --prop spaceBefore=18pt --prop spaceAfter=4pt
officecli add "$FILE" /body --type sdt --prop type=text \
--prop alias="Signatory Name" --prop tag=signatory_name --prop text="Authorized Signatory" \
--after 'find:Client Signature:'
# (Only reach for raw-set if a listItem's stored value must differ from its display text — see Path B.)
```
### Recipe (sow-c) Watermark + locks + document protection
Drops the CONFIDENTIAL watermark (parent is `/`, never `/body`), locks the three block-level SDTs, instructs how to lock the inline signatory_name SDT (path only known after `view forms`), then seals the document with `protection=forms` as the last command.
```bash
officecli add "$FILE" / --type watermark \
--prop text="CONFIDENTIAL" --prop color=FF0000 --prop rotation=315
officecli set "$FILE" '/body/sdt[1]' --prop lock=sdtlocked
officecli set "$FILE" '/body/sdt[2]' --prop lock=sdtlocked
officecli set "$FILE" '/body/sdt[3]' --prop lock=sdtlocked
officecli view "$FILE" forms # copy signatory_name path, then: set '/body/p[@paraId=...]/sdt[1]' --prop lock=sdtlocked
officecli set "$FILE" / --prop protection=forms
officecli close "$FILE"
officecli query "$FILE" field # expect 2 MERGEFIELDs: CustomerName, ContractNo
```
## Design principles (forms)
**Control-type decision tree:**
```
Date → type=date | Fixed list → type=dropdown | List + custom → type=combobox
Short text → type=text | Long text → type=richtext | Boolean → formfield checkbox
```
**Typography scale.** Spacing unit trap: `spaceBefore` / `spaceAfter` / `spaceLine` default to **twips** (1/20 pt) — always write `spaceBefore=18pt`.
| Element | Size | Style | Spacing |
|---|---|---|---|
| Form title (H1) | 20pt | Bold | `spaceBefore=0pt`, `spaceAfter=12pt` |
| Section heading (H2) | 14pt | Bold | `spaceBefore=18pt`, `spaceAfter=8pt` |
| Field label | 11pt | Bold | `spaceAfter=4pt` |
| Instructions / notes | 11pt | Italic `color=666666` | `spaceAfter=18pt` |
**Accessibility bump.** For medical / geriatric / accessibility-focused forms, raise field label + instruction to **12pt** (11pt default is tight for older users); keep section headings at 14pt.
**CJK forms:** set `docDefaults.font="Microsoft YaHei"` — Calibri lacks Chinese glyphs.
**Field ordering.** (1) Personal / ID, (2) role / classification, (3) dates, (4) supplemental free-text, (5) confirmation / signature.
**Yes/No + conditional follow-up** (common in compliance / medical intake): formfield checkbox followed by a richtext SDT whose `alias` carries the cue — e.g. `--type formfield --prop type=checkbox --prop name=has_cond` then `--type sdt --prop type=richtext --prop alias="If yes, explain" --prop tag=cond_detail --prop text="If yes, explain here"`.
**Signature block order.** Label on its own paragraph, SDT on the next paragraph (with `spaceBefore=18pt` on the label, `spaceAfter=4pt` on the SDT). Never `Label: SDT` inline — Word renders the runs as touching, visually stuck together.
**Build order.** create+open → metadata → structure (headings, label paragraphs) → SDT/formfield skeletons (Path A 4 props) → Path B injections → per-field lock → `protection=forms` LAST → close.
**Header / footer note.** Headers/footers are **predefined** when the section is created (default/first/even, 3 each). The first mutation must be `set` against the existing part, not `add` — `add $FILE /header ...` returns `already exists` or silently no-ops. Inspect first with `officecli query "$FILE" header --json` to read the `type` values, then `officecli set "$FILE" '/header[@type=default]' --prop text=...`. Only use `add` when creating an additional section with its own header/footer.
## Batch mode (brief)
For forms with many controls, batch reduces overhead. Path A + Path B coexist in one batch.
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/body","type":"sdt","props":{"type":"text","alias":"Full Name","tag":"full_name","text":"Enter name"}},
{"command":"add","parent":"/body","type":"sdt","props":{"type":"dropdown","alias":"Department","tag":"dept"}},
{"command":"raw-set","part":"/document","xpath":"//w:sdt[w:sdtPr/w:tag/@w:val='dept']/w:sdtPr/w:dropDownList","action":"append","xml":"<w:listItem xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" w:displayText=\"Engineering\" w:value=\"Engineering\"/><w:listItem xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" w:displayText=\"Finance\" w:value=\"Finance\"/>"},
{"command":"set","path":"/body/sdt[1]","props":{"lock":"sdtlocked"}},
{"command":"set","path":"/body/sdt[2]","props":{"lock":"sdtlocked"}}
]
EOF
officecli set "$FILE" / --prop protection=forms
```
- Escape inner `"` in `xml` with `\"`. Use single-quoted heredoc `<<'EOF'` so `$var` does not expand.
- **P0 batch trap:** genuinely-unsupported props in batch are silently dropped, **no WARNING** (interactive `add` would print WARNING: UNSUPPORTED, exit 2). Defence: send only props `help docx sdt` lists (`type/tag/alias/text/items/format/lock/...` are fine on `add`; `maxlength` is not), and verify with a readback after the batch.
- `batch` supports `add`, `set`, `get`, `query`, `remove`, `validate`, `raw-set`.
## Delivery Gate (executable)
Run every gate below after every form. Each gate must print its `OK` line. Any `REJECT` = do not deliver.
```bash
# Assumes FILE=<your-form.docx>, document has been closed with officecli close "$FILE"
# Gate 1 — Validate (must be clean; protection=forms no longer produces a schema error)
VAL_OUT=$(officecli validate "$FILE" 2>&1)
VAL_ERRS=$(echo "$VAL_OUT" | grep -c '\[Schema\]')
if [ "$VAL_ERRS" -eq 0 ]; then echo "Gate 1 OK (validate clean)"
else echo "REJECT Gate 1: $VAL_ERRS schema errors"; echo "$VAL_OUT"; exit 1
fi
# Gate 2 — Token / placeholder leak (labels used as visual underscore substitutes)
LEAK=$(officecli view "$FILE" text | grep -niE '_{3,}|TBD|\(fill in\)|\{\{|xxxx|lorem|placeholder')
[ -z "$LEAK" ] && echo "Gate 2 OK (no underscore / placeholder leak)" || { echo "REJECT Gate 2:"; echo "$LEAK"; exit 1; }
# Gate 3 — At least one structured field exists
SDT_N=$(officecli query "$FILE" sdt --json | jq '.data.results | length')
FF_N=$(officecli query "$FILE" formfield --json | jq '.data.results | length')
FLD_N=$(officecli query "$FILE" field --json | jq '.data.results | length')
TOTAL=$((SDT_N + FF_N + FLD_N))
[ "$TOTAL" -gt 0 ] && echo "Gate 3 OK ($SDT_N sdt + $FF_N formfield + $FLD_N field)" || { echo "REJECT Gate 3: 0 structured fields — this is not a form"; exit 1; }
# Gate 4 — Every SDT has alias + tag (skill-imposed H2)
# NOTE: `query`/`get --json` wrap prop fields under `.data.results[N].format.{prop}` — use `.data.results[0].format.alias` / `.format.tag`, never bare `.alias` or `.data.format`.
SDT_MISSING=$(officecli query "$FILE" sdt --json | jq '[.data.results[] | select(.format.alias == null or .format.alias == "" or .format.tag == null or .format.tag == "")] | length')
[ "$SDT_MISSING" -eq 0 ] && echo "Gate 4 OK (every SDT has alias+tag)" || { echo "REJECT Gate 4: $SDT_MISSING SDT(s) missing alias or tag"; exit 1; }
# Gate 5 — Protection enforced + per-field lock inventory
PROT=$(officecli get "$FILE" / --json | jq -r '.data.results[0].format.protection // "none"')
[ "$PROT" = "forms" ] && echo "Gate 5 OK (protection=forms enforced)" || { echo "REJECT Gate 5: protection is '$PROT', expected 'forms'"; exit 1; }
officecli view "$FILE" forms | head -40 # visual spot-check: every dropdown shows items=; every date shows format=; every locked SDT shows lock=
# Gate 6 — No type=checkbox leaked onto any SDT
BAD_CB=$(officecli query "$FILE" sdt --json | jq '[.data.results[] | select(.format.type == "checkbox")] | length')
[ "$BAD_CB" -eq 0 ] && echo "Gate 6 OK (no SDT checkbox — formfield only)" || { echo "REJECT Gate 6: $BAD_CB SDT with type=checkbox"; exit 1; }
```
**Why `view issues` is not a gate.** It runs only prose-style checks (first-line-indent, heading size) and flags every form label as `Body paragraph missing first-line indent` — a false-positive avalanche on forms. Ignore for this skill. Use `validate` (schema integrity) and `view forms` (field inventory).
## Known Issues
| # | Issue | Behavior | Workaround |
|---|---|---|---|
| K1 | SDT `type=checkbox` not implemented | `add ... --type sdt --prop type=checkbox` → `Error: SDT type 'checkbox' is not implemented`, exit 1 (error now lists the supported types incl. group/picture) | Use `--type formfield --prop type=checkbox` |
| K3 | SDT `maxlength` UNSUPPORTED on `add` | `WARNING: UNSUPPORTED: maxlength`, exit 2; element still created. (`items` / `format` / `lock` / `placeholderText` are NOW supported on `add` — only `maxlength` is rejected; `name` is accepted but a no-op on SDT — use `alias`/`tag`) | Enforce length downstream; use `text` for initial content |
| K4 | SDT `items` / `format` / `type` not settable AFTER creation | `set --prop items=...` → `UNSUPPORTED props (use raw-set instead)`. (They ARE settable on `add` — set them inline at creation) | Set at `add` time; to change later, Path B `raw-set` or `remove` + re-add |
| K5 | FormField `maxlength` UNSUPPORTED | `WARNING: UNSUPPORTED: maxlength`; formfield created | Enforce length in downstream validation |
| K6 | FormField dropdown `items` UNSUPPORTED | Dropdown formfield is created with empty option list | Use an SDT dropdown with `--prop items=` instead |
| K7 | Watermark `width` / `height` not settable | Watermark created without them; `opacity` IS settable on `add` and reads back (real dims are computed) | For an exact size, open Word + adjust the shape (Phase 2) |
| K9 | Batch mode silently drops genuinely-UNSUPPORTED props | No `WARNING` line; batch reports "N succeeded" even when a prop (e.g. `maxlength`) was dropped | Keep batch SDT entries to props `help docx sdt` lists; verify with a readback after the batch |
| K13 | FormField `name` > 20 characters | `add` returns exit 0 with no warning; `validate` later reports `[Schema] ... MaxLength=20` on `/w:ffData/w:name` | Keep `name` ≤ 20 characters (OpenXML schema limit). SDT `alias` / `tag` have no such limit |
| K14 | `shd.fill` on a paragraph emits schema-invalid `<w:pPr>/<w:shd>` | `validate` reports 2 schema errors per instance (`unexpected child element`, `required attribute 'val' missing`); Word renders it anyway | Apply highlight on the run instead (`shading=HEX`, flat canonical), or raw-set `<w:shd w:val="clear" w:fill="HEX"/>` into the run's `<w:rPr>` |
| K15 | `view forms` does NOT list MERGEFIELDs | Only SDT + formfield in output; MERGEFIELDs are template-time, not end-user fillable | Treat `query field` and `view forms` as two disjoint inventories. Every recipe verifies both |
| K16 | Header / footer are predefined at section creation (default/first/even, 3 each) | `add $FILE /header ...` returns `already exists` or silently no-ops on the first call | First mutation uses `set` against the existing part: `officecli query $FILE header --json` to read `type`, then `set '/header[@type=default]' --prop text=...`. Only use `add` for a brand-new section's header/footer |
| K17 | Watermark injected into header emits `<w:noProof>` child that is schema-invalid | `validate` adds an extra `[Schema]` error at `/header[N]/w:sdt/.../w:noProof` — a real schema error to fix, not a benign one | After `add $FILE / --type watermark`, run once per header part: `officecli raw-set $FILE /word/header1.xml --xpath "//w:noProof" --action remove` (repeat for `header2.xml`, `header3.xml` if present) |
| K18 | `query`/`get --json` wrap prop fields under `.data.results[N].format.{prop}` | Writing jq against bare `.alias` / `.tag` / `.protection`, or `.data.format.*`, returns null/0 matches and Gate 4/5 falsely report "missing=N" | Use `.data.results[].format.alias` / `.format.tag`; for `get /` use `.data.results[0].format.protection` (NOT `.data.format.protection`). Same for `.format.type` / `.format.paraId` |
| K19 | LibreOffice renders formfield checkbox as `☐☐` (double box) in PDF export | Cosmetic only — Word / WPS render a single box, clickable to toggle ☑. A LibreOffice renderer quirk, flagged as [RENDERER-BUG] | Do not try to "fix" in the skill. If an evaluator screenshots from LibreOffice-generated PDF and sees `☐☐`, attribute to [RENDERER-BUG], not a form-quality defect |
## Phase 2 — enhance in Word
Some polish is out of CLI scope. Hand the file to a human for these; none are required for a valid form.
| Need | Why open Word |
|---|---|
| Real SDT checkbox with specific locking | `type=checkbox` exits 1; use Developer → Check Box Content Control (or a legacy FormField checkbox via CLI) |
| Prompt text ("Click here to enter a date") | Needs `placeholderDocPart` in `/word/glossary/document.xml` |
| Custom richtext default appearance | Adjust the referenced style in Word's style pane |
| Watermark resize | `width` / `height` not settable; drag shape handles |
(`picture` and `group` SDTs add directly via `--type sdt --prop type=picture` / `type=group` — no longer Phase-2 items.)
For the first four, build the skeleton once (Path C) and reuse.
## Help pointer
When in doubt: `officecli help docx`, `officecli help docx <element>`, `officecli help docx <element> --json`. Help is the authoritative schema; this skill is the decision guide for building real fillable Word forms on top of it.
+497
View File
@@ -0,0 +1,497 @@
---
name: officecli-xlsx
description: "Use this skill any time a .xlsx file is involved -- as input, output, or both. This includes: creating spreadsheets, financial models, dashboards, or trackers; reading, parsing, or extracting data from any .xlsx file; editing, modifying, or updating existing workbooks; working with formulas, charts, pivot tables, or templates; importing CSV/TSV data into Excel format. Trigger whenever the user mentions 'spreadsheet', 'workbook', 'Excel', 'financial model', 'tracker', 'dashboard', or references a .xlsx/.csv filename."
---
# OfficeCLI XLSX Skill
## Setup
If `officecli` is missing:
- **macOS / Linux**: `curl -fsSL https://d.officecli.ai/install.sh | bash`
- **Windows (PowerShell)**: `irm https://d.officecli.ai/install.ps1 | iex`
Verify with `officecli --version` (open a new terminal if PATH hasn't picked up). If install fails, download a binary from https://github.com/iOfficeAI/OfficeCLI/releases.
## ⚠️ Help-First Rule
**This skill teaches what good xlsx looks like, not every command flag. When a property name, enum value, or alias is uncertain, consult help BEFORE guessing.**
```bash
officecli help xlsx # List all xlsx elements
officecli help xlsx <element> # Full element schema (e.g. pivottable, chart, cf)
officecli help xlsx <verb> <element> # Verb-scoped (e.g. add chart, set cell)
officecli help xlsx <element> --json # Machine-readable schema
```
Help reflects the installed CLI version. When this skill and help disagree, **help is authoritative**.
## Shell & Execution Discipline
**Shell quoting (zsh / bash).** Excel paths contain `[]`, and number formats contain `$`. Both are shell metacharacters. Rules:
- ALWAYS quote element paths: `"/Sheet1/row[1]"`, not `/Sheet1/row[1]`.
- Use **single quotes** for any prop value containing `$`: `numFmt='$#,##0'`.
- For formulas with cross-sheet `!` references, use `batch` with a `<<'EOF'` heredoc (see Known Issues).
- `\n` and `\t` in a prop value ARE interpreted by the CLI — `\n` is a real in-cell line break (pair with `--prop wrapText=true`), `\t` a tab — consistent across xlsx / docx / pptx. Double them (`\\n`) for a literal backslash-n (rarely wanted). (`$` is the shell layer above — single-quote it.)
**Incremental execution.** Run commands one at a time and read each exit code. `officecli` mutates the file on every call; a 50-command script that fails at command 3 will cascade silently. One command → check output → continue.
## Requirements for Outputs
Before reaching for a command, know what a good xlsx looks like. These are the deliverable standards every workbook MUST meet.
### All Excel files
**Zero formula errors.** Every delivered workbook MUST have ZERO `#REF!`, `#DIV/0!`, `#VALUE!`, `#NAME?`, `#N/A`. No exceptions — guard denominators with `IFERROR` or `IF(x=0,...)`.
**Formulas, not hardcoded values.** If a number can be computed from other cells, it is a formula. Hardcoding `5000` where `=SUM(B2:B9)` belongs breaks the contract that the workbook stays live when inputs change. This is the single most important rule in this skill.
**Professional font.** Use one consistent, professional font across the workbook (Arial / Calibri / Times New Roman). Don't mix four fonts because one sheet came from CSV.
**Explicit widths.** There is no auto-fit. Any column the user will read MUST have `width` set — default 8.43 chars clips everything. Sensible starts: labels 20-25, numbers 12-15, dates 12, short codes 8-10.
**Preserve existing templates.** When editing a file that already has a look, match it. Existing conventions override these guidelines.
### Visual delivery floor (applies to EVERY workbook)
Before you declare done, run `officecli view "$FILE" html` and Read the returned HTML path to confirm all of these:
- **No `###` in any cell.** `###` means a column is too narrow for its widest value. Every column the user reads needs an explicit `width`. `###` in a delivered file is unfinished work, never "a small visual nit".
- **No truncated titles.** Sheet titles, section headers, long labels must fit. Widen the column or apply `wrapText=true` on the cell.
- **No placeholder tokens rendered as data.** `$fy$24`, `{var}`, `<TODO>`, `xxxx` must never appear in a cell, chart title, series name, or legend. These are build-time tokens that escaped replacement.
- **Pie / doughnut slices have distinct fill colors.** If the slices render same-colored, switch to `bar` / `column` or set `colors=...` explicitly.
- **No empty trailing pages / empty chart anchors.** `anchor=D2:J18` over empty source cells looks like a broken chart.
If any of the above fails, STOP and fix before declaring done.
**Print layout.** Any sheet the user may print or send as a board pack needs page setup. Default portrait + no fit-to-page splits wide tables and charts mid-way. Pick the fit mode by sheet shape:
```bash
# Summary / chart / dashboard sheet (small, ≤ ~40 rows): fit to a single page.
officecli set "$FILE" "/Summary" --prop orientation=landscape --prop fitToPage=true
# Tall data table (dozens+ rows): fit WIDTH only, let height paginate naturally.
# fitToPage=true here crushes every row onto one page → unreadable (### dates, 5px rows).
officecli set "$FILE" "/Data" --prop orientation=landscape --prop fitToPage=1x0
```
`fitToPage=true` == `1x1` == fit both axes to one page — correct only when the sheet is already short. `1x0` = fit 1 page wide, unlimited pages tall. Trigger: sheet holds a chart, or > 8 columns, or the user's ask mentions print / board / investor.
### Financial models only — skip this section if you are building a template, tracker, CSV import, or operational sheet
Scope: budgets, forecasts, 3-statement models, valuation, any `$`-heavy analytical workbook. A customer-support tracker or onboarding template does not need this section.
**Color coding — industry standard.** Five core colors used as a language, not decoration. A reviewer should tell what a cell IS by color alone — before reading the formula.
| Color | Role | Example |
|---|---|---|
| Blue text `0000FF` | Hardcoded inputs, scenario variables | `font.color=0000FF` |
| Black text `000000` | ALL formulas and calculations | default |
| Green text `008000` | Cross-sheet links inside this workbook | `font.color=008000` |
| Red text `FF0000` | Links to external files / workbooks | `font.color=FF0000` |
| Yellow fill `FFFF00` | Key assumptions needing review | `fill=FFFF00` |
A reviewer should tell what a cell IS just by its color — before reading the formula. This is a communication contract, not a cosmetic preference.
**Number formatting — standards, not preferences.**
- **Years** are text, not numbers. Format `2026` not `2,026` — use `numFmt="@"` or set `type=string`.
- **Currency** carries its unit in the header (`Revenue ($mm)`), not in every cell.
- **Zeros display as `-`**, not `0`. Use `$#,##0;($#,##0);"-"`.
- **Percentages** default to one decimal: `0.0%`.
- **Negatives use parentheses**: `(1,234)` not `-1,234`.
- **Valuation multiples** use `0.0x` format (EV/EBITDA, P/E, etc.).
**Assumptions live in cells, not inside formulas.** `=B5*(1+$B$6)` is correct; `=B5*1.05` is a bug. Document each blue hardcoded input with an adjacent source note in the next cell or a cell comment:
```
Source: Company 10-K, FY2024, Page 45, Revenue Note
Source: Bloomberg, 2026-05-02, AAPL US Equity
Source: Management guidance, Q2 2026 earnings call
```
Any hardcoded number without a source is an undocumented assumption — a reviewer cannot audit it.
## Common Workflow
Six steps. Every non-trivial build follows this shape.
1. **Open/save lifecycle.** Use `officecli open <file>` at the start and `officecli save <file>` at the end to flush to disk — `save` only writes and leaves the resident warm for follow-up edits; reach for `officecli close <file>` only to release the resident on a one-shot handoff. Both are always safe (never error or lose work). For many cells, use `batch`: **≤ 50 ops/block recommended; tested up to 80+ ops per block on pure value-set payloads with zero failures. Cross-sheet formula batches are the exception — run those non-resident, single heredoc (see Known Issues)**. **Flush only at the non-officecli boundary:** officecli's own reads always see your edits; run `save`/`close` only before a non-officecli program reads the file (openpyxl/pandas, Excel, a renderer, delivery).
2. **Create or load.** `officecli create "$FILE"` (new) or `officecli view "$FILE" outline` (existing — get the lay of the land first).
3. **Build incrementally.** One command, read the output, continue. After any structural op (new sheet, chart, named range, pivot), run `get` on it to confirm shape before stacking more on top.
4. **Format.** Column widths, number formats, freeze panes, tab colors, header fills. Formatting is not optional polish — per "Requirements for Outputs" it is part of the deliverable.
5. **Save, then reckon with the cache.** `officecli save <file>` writes to disk. Newly-added formulas ship without cached values; when a human opens the file in a spreadsheet app, the app recalculates and populates them. **But your downstream `INDEX/MATCH`, `SUMPRODUCT`, or any formula that references an upstream formula will cache whatever the upstream cached at write-time — often `0` or a stale value — and that cached lie survives into non-recalculating readers.** After any multi-formula build involving array formulas (`SUMPRODUCT`, `SUMIFS` with dynamic criteria) or cross-sheet chains, **re-touch every downstream cell** (run `set` again with the same formula) so the engine recomputes its cache from the freshly-cached upstream. ⚠️ Re-touch on cross-sheet chains via resident is unreliable (see Batch / resident caveats) — prefer non-resident `set` for the re-touch pass. Then `officecli get` a few downstream cells and eyeball that their `cachedValue=` is plausible. `validate` is safe with a resident open and itself flushes pending edits to disk (same as docx / pptx).
6. **QA — assume there are problems.** See the QA section. You are not done when your last command exited 0; you are done after one fix-and-verify cycle finds zero new issues.
## Quick Start
Minimal viable xlsx: 3 months of revenue + a total formula + column widths + a currency format. Adapt, don't copy-paste — your file, your data.
```bash
officecli create "$FILE"
officecli open "$FILE"
officecli set "$FILE" /Sheet1/A1 --prop value=Month --prop bold=true
officecli set "$FILE" /Sheet1/B1 --prop value=Revenue --prop bold=true
officecli set "$FILE" /Sheet1/A2 --prop value=Jan
officecli set "$FILE" /Sheet1/A3 --prop value=Feb
officecli set "$FILE" /Sheet1/A4 --prop value=Mar
officecli set "$FILE" /Sheet1/B2 --prop value=42000 --prop numFmt='$#,##0'
officecli set "$FILE" /Sheet1/B3 --prop value=45000 --prop numFmt='$#,##0'
officecli set "$FILE" /Sheet1/B4 --prop value=48000 --prop numFmt='$#,##0'
officecli set "$FILE" /Sheet1/A5 --prop value=Total --prop bold=true
officecli set "$FILE" /Sheet1/B5 --prop formula="SUM(B2:B4)" --prop bold=true --prop numFmt='$#,##0'
officecli set "$FILE" "/Sheet1/col[A]" --prop width=12
officecli set "$FILE" "/Sheet1/col[B]" --prop width=15
officecli close "$FILE"
officecli validate "$FILE"
```
Verified: `validate` returns `no errors found`, `B5` resolves to `135000`. This is the shape of every build: open → set cells/formulas → format → close → validate.
## CSV / bulk import
**Native `import` command (preferred for CSV/TSV).** Fastest path; loads a CSV into a sheet in one call. `--header` sets AutoFilter + freeze pane on row 1. Widths and `numFmt` still need a follow-up pass (per D-12 in Dashboard skill).
```bash
officecli import "$FILE" /Sheet1 --file data.csv --header
officecli import "$FILE" /Sheet1 --file data.tsv --format tsv --header
officecli import "$FILE" /Sheet1 --stdin --start-cell B2 < data.csv
```
**Python + batch fallback** — use when you need custom type coercion, formula injection, or the CSV lives inside another data pipeline. Recipe for 600-6000+ cells:
```python
# gen_batch.py — produces batch chunks of 80 value-set ops each
import csv, json
ops = []
with open("data.csv") as f:
reader = csv.reader(f)
for r, row in enumerate(reader, start=1):
for c, val in enumerate(row):
col = chr(ord('A') + c)
ops.append({"command":"set","path":f"/Data/{col}{r}",
"props":{"value": val}})
for i in range(0, len(ops), 80):
print(json.dumps(ops[i:i+80]))
```
```bash
python gen_batch.py | while IFS= read -r chunk; do
printf '%s\n' "$chunk" | officecli batch "$FILE"
done
```
Outcome: 648-row retail CSV (6490 cells) loads in ~30s, zero failures. Tune: start at 80 ops/chunk, drop to 40 if any chunk fails. Numeric type inference and formulas come later via targeted `set` — batch in this recipe is pure value injection.
## Reading & Analysis
Start wide, then narrow. `outline` first tells you what sheets exist and where the data is; jump into `view` / `get` / `query` only once you know where to look.
**Open the rendered workbook to eyeball your own work.**
- `officecli view $FILE html` — Read the returned HTML to audit the rendered output. Each sheet is addressable, charts render inline. Catches `###`, placeholder leakage, pivot layout, row-height clipping.
- `officecli watch $FILE` keeps a live preview running for the human user — they open it at their own discretion. Use when the user wants to watch along; agent self-check uses `view html` above.
Use `view html` as your **first visual check after a batch of edits** — fix at source. For final visual verification, the user opens the `.xlsx` in their Excel / WPS / Numbers viewer.
**Orient.** Sheets, dimensions, formula counts.
```bash
officecli view "$FILE" outline
```
**Extract.** Plain text dump for content QA or LLM context; scope with `--start` / `--end` / `--cols` for big files.
```bash
officecli view "$FILE" text --start 1 --end 50 --cols A,B,C
```
Other `view` modes worth knowing: `annotated` (cell values + types/formulas + warnings), `stats` (numeric summaries), `issues` (broken formulas, empty sheets, missing refs).
**Round-trip dump.** `officecli dump "$FILE" [path]` serializes the workbook — or one worksheet (`/Sheet1`, `/sheet[N]`) — into a replayable batch JSON; `officecli batch new.xlsx --input dump.json` replays it. Use it to learn from an existing workbook's structure or clone/adapt a template instead of reading raw OOXML. Coverage per `dump --help`; subtree dumps don't carry workbook-level resources (settings, named ranges) — the replay target must already define them.
```bash
officecli dump "$FILE" -o blueprint.json # whole workbook
officecli dump "$FILE" /Sheet1 -o sheet.json # one worksheet
officecli batch new.xlsx --input blueprint.json
```
**Inspect one element.** Use XPath-style paths. Always quote — shells glob `[N]`.
```bash
officecli get "$FILE" "/Sheet1/A1" # one cell
officecli get "$FILE" "/Sheet1/A1:D10" # range
officecli get "$FILE" "/Sheet1/chart[1]" # chart
officecli get "$FILE" "/Sheet1/table[1]" # ListObject
officecli get "$FILE" "/namedrange[1]" # workbook-level named range
```
Add `--depth N` to expand children; add `--json` for machine output. Full element list: `officecli help xlsx`.
**Query across the workbook.** CSS-like selectors. Use for systematic checks (formula coverage, error cells, empty headers) rather than hand-walking.
```bash
officecli query "$FILE" 'cell:has(formula)' # every formula cell
officecli query "$FILE" 'cell:contains("#REF!")' # broken references
officecli query "$FILE" 'cell[type=Number]' # typed filter
officecli query "$FILE" 'Sheet1!B[value!=0]' # sheet-scoped
```
Operators: `=`, `!=`, `~=` (contains), `>=`, `<=`, `[attr]` (exists).
**Merge cells shortcut.** `officecli query $FILE merge` or `mergedrange` — both are aliases for `mergeCell`. Returns every merged range in the workbook without hand-walking `<mergeCell>` entries.
**When the data is big enough that a row-walk is useless**, reach for Excel's own analytical elements:
- Build a **pivot table** with `officecli add` (`--type pivottable`) to group/aggregate without writing 20 SUMIFs. Attach a **slicer** (`--type slicer`) to give the reader a filter UI.
- Drop a **sparkline** (`--type sparkline`) in a row to show per-row trends — cheaper than one line chart per row and they print inline. `type` is a strict enum: **`line | column | stacked`** (plus aliases `winloss` / `win-loss` → `stacked`). Invalid `type=` values hard-fail — no silent fallback to `line` anymore.
- Run `officecli help xlsx pivottable`, `officecli help xlsx slicer`, `officecli help xlsx sparkline` for the exact prop names.
## Creating & Editing
Ninety percent of a build is cells, formulas, formatting, and one or two charts. The verbs: `add` (new element), `set` (change a prop), `remove`, `move`, `swap`, `batch`.
### Cells and formulas
Set a value and its format in one call. Never write `=` at the start of a formula — the CLI strips it.
```bash
officecli set "$FILE" /Sheet1/B5 --prop formula="SUM(B2:B4)" --prop numFmt='$#,##0'
officecli set "$FILE" /Sheet1/C5 --prop formula="B5/A5" --prop numFmt="0.0%"
```
Structural properties (width, height, freeze, tabColor) live on row / col / sheet nodes:
```bash
officecli set "$FILE" "/Sheet1/col[A]" --prop width=20
officecli set "$FILE" "/Sheet1/row[1]" --prop height=22
officecli set "$FILE" "/Sheet1" --prop freeze=A2 --prop tabColor=1F4E79
```
### Named ranges
Prefer named ranges over `$B$6` in formulas. They self-document (`GrowthRate` beats `$B$6`) and they let you move the assumption cell without breaking formulas. Because `ref` values contain both `!` and `$`, add them through a batch heredoc:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/","type":"namedrange","props":{"name":"GrowthRate","ref":"Sheet1!$B$6"}}
]
EOF
```
See `officecli help xlsx namedrange` for the full schema.
**Batch JSON does NOT accept shell aliases.** Inside batch `props`, always use the full dotted name — `"font.color": "FF0000"`, `"font.size": 14`, never `"color": "FF0000"` (ambiguous: text vs fill). On a bare cell, even the shell form is rejected: `--prop color=1F4E79` errors with `ambiguous in cell context — use 'font.color' (text) or 'fill' (bg)`. Rule: in any batch JSON or cell prop, write `font.color` / `fill` explicitly. `parent` should be `"/"` for workbook-level elements and `"/SheetName"` for sheet-scoped; empty string is not equivalent.
### Charts
Chart types live under `officecli help xlsx chart` — the enum is long (20+). Pick the right one for the message: column for category comparison, line for time series, pie only when slices are self-evidently proportional, scatter for correlation. Avoid exotic types unless they answer a specific question.
**Three ways to feed chart data. Pick one per chart — mixing them at add-time is a common trap.**
| Form | Shape | When to use |
|---|---|---|
| (a) inline `data` | `--prop data="Sales:100,200,300" --prop categories="Jan,Feb,Mar"` | Tiny demo charts, numbers you will not edit. Source of truth lives in the chart XML, not a cell. |
| (b) 2D `dataRange` | `--prop dataRange="Sheet1!A1:B4"` (first col = categories, first row = header / series name) | Normal case. Must be **2-D** — single column fails with "Chart requires data". |
| (c) dotted per-series | `--prop series1.name=Sales --prop series1.values="Sheet1!B2:B4" --prop series1.categories="Sheet1!A2:A4"` | Multi-series charts where each series points at non-contiguous ranges, or you want explicit series naming. `series1.values` alone (no `categories`) emits a chart with `1,2,3` as the x-axis. |
**The single-column trap.** `dataRange="Sheet1!B2:B13"` looks like "value column" but the engine rejects it with `Chart requires data`. Either widen the range to include the category column (`A2:B13`), or switch to form (c) with explicit `series1.categories`.
**Move / resize a chart after create:** `set chart[N] --prop anchor="F5:N25"` (also `--prop x= --prop y= --prop width= --prop height=`). **Series are still immutable** — to add/change a series, `officecli remove` the chart and `officecli add` with the full series list. Note `remove chart[1]` shifts `chart[2] → chart[1]` and re-add **appends at the end** — to preserve chart order, remove all and rebuild in order.
**Anchor sizing.** No auto-fit. A column chart with 5-6 categories + 2 series needs roughly `A5:L22` (12 cols × 18 rows) to show all labels uncut. Narrower and X-axis labels clip; wider and the chart can split across pages on print/export. If in doubt, start narrow, preview via `view html` (Read the returned HTML path), widen in increments. Page layout (below) is the other half of the fix.
**Chart `dataRange` — always prefix with the sheet.** Even when the chart lives on the same sheet, write `dataRange="Summary!A17:C22"`, not `A17:C22`. The sheet-less form works inconsistently; the prefixed form is 100% reliable.
officecli adds extended chart types the classic Excel object model lacks: `boxWhisker`, `waterfall`, `funnel`, `histogram`, `treemap`, `sunburst`, `pareto`. Use them when the data calls for them.
**NEVER put unreplaced template tokens in chart title / series name / legend / axis title.** `$fy$24`, `{var}`, `<TODO>`, `$VAR`, `{{placeholder}}` render **literally** in the legend — validate passes, but a CFO sees `$fy$24` where "FY2024" should be. Always bind to final text or a cell reference (`title="FY2024 Revenue"` or `series1.name="Sheet1!A1"`).
### Conditional formatting
Three common flavors, each with its own prop shape (consult `officecli help xlsx cf`):
- **Color scales**: cells shaded on a gradient by value — `type=colorscale` with `minColor` / `midColor` / `maxColor`.
- **Data bars**: in-cell bars showing magnitude — `type=databar`. Set explicit `min` / `max` for consistent scaling across a column; defaults are valid if you omit them.
- **Formula rules** (the `formulacf` element): highlight row when a condition is true — `type=formula` with `formula="$C2>1000"` and a fill/font.
Rule: apply CF sparingly. A workbook where every cell is colored tells the reader nothing.
### Data validation
Input cells in trackers and templates MUST carry data validation. It's cheap and it stops entire classes of downstream bugs. **Three list-source patterns** — pick based on where the allowed values live.
**(a) Inline list** — allowed values are short and fixed in the rule itself.
```bash
officecli add "$FILE" /Sheet1 --type validation \
--prop sqref="C2:C100" --prop type=list \
--prop formula1="Yes,No,Maybe" \
--prop showError=true --prop errorTitle="Invalid" --prop error="Select from list"
```
**(b) Named range (preferred for cross-sheet lookups)** — allowed values live in another sheet and may grow. Define the named range first, then reference it. Use a batch heredoc because `ref` contains `!` and `$`:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/","type":"namedrange","props":{"name":"StatusList","ref":"Lookups!$A$2:$A$4"}},
{"command":"add","parent":"/Sheet1","type":"validation","props":{"sqref":"B2:B100","type":"list","formula1":"=StatusList"}}
]
EOF
```
**(c) Direct cross-sheet range** — no named range, raw `Lookups!$A$2:$A$4` inside `formula1`. Also needs a batch heredoc to keep `!` and `$` intact:
```bash
cat <<'EOF' | officecli batch "$FILE"
[
{"command":"add","parent":"/Sheet1","type":"validation","props":{"sqref":"C2:C100","type":"list","formula1":"Lookups!$A$2:$A$4"}}
]
EOF
```
If you write the cross-sheet variant as `--prop formula1=...` on the shell, the `!` gets shell-mangled into `\!` and the dropdown will silently fall back to no list. Verify with `officecli get "$FILE" /Sheet1/validation[N]` — `formula1=` must show a plain `!`, no backslash.
Other common `type` values: `decimal`, `whole`, `date`, `textLength`, `custom`. See `officecli help xlsx validation` for operators and the full prop list.
### Other elements (one-liners)
- **Tables** (ListObjects) — `add --type table` with a range; gives auto-filter + structured refs. `officecli help xlsx table`.
- **Comments** — `add --type comment`; use for documenting hardcoded assumptions. `officecli help xlsx comment`.
- **Sheet reordering** — `officecli move`, not `swap`. `swap` only works on row/cell paths.
- **New row/column with the neighbours' formatting** — `add "$FILE" /Sheet1 --from "/Sheet1/row[5]" --index 5` clones fills, fonts and formulas (refs shift); `add --type row` inserts a bare row.
## Chart Axis-by-Role
Editing a chart axis in place is cheaper than rebuilding the chart. Address axes by **role** (`value` = Y, `category` = X), not by index — the XML order isn't stable.
```bash
officecli get "$FILE" "/Sheet1/chart[1]/axis[@role=value]"
officecli set "$FILE" "/Sheet1/chart[1]/axis[@role=value]" --prop min=0 --prop max=100000
officecli set "$FILE" "/Sheet1/chart[1]/axis[@role=category]" --prop title="Month"
```
Safe props: `title`, `min`, `max`, `majorGridlines`, `visible`, `labelRotation`.
## QA (Required)
**Assume there are problems. Your job is to find them.**
Your first workbook is almost never correct. Treat QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you were not looking hard enough. The formulas look fine **until** you check two of them against source cells.
### Minimum cycle before "done"
1. `officecli view "$FILE" issues` — empty sheets, broken formulas, missing refs.
2. `officecli view "$FILE" annotated` (sample ranges) — values + types + warnings.
3. For every Excel error type, query it:
```bash
officecli query "$FILE" 'cell:contains("#REF!")'
officecli query "$FILE" 'cell:contains("#DIV/0!")'
officecli query "$FILE" 'cell:contains("#VALUE!")'
officecli query "$FILE" 'cell:contains("#NAME?")'
officecli query "$FILE" 'cell:contains("#N/A")'
```
4. `officecli validate "$FILE"` — safe with a resident open; `validate` flushes pending edits to disk itself.
5. **Visual pass — walk every sheet via the HTML preview.** Run `officecli view "$FILE" html` and Read the returned HTML path. Each sheet renders with charts inline. Scan for `###`, truncated titles, placeholder tokens (`$fy$24`, `{var}`, `<TODO>`), sliced charts, white-slice pie charts, empty chart anchors — **STOP and fix before declaring done**. "validate pass" is not delivery; "the preview looks like a real workbook" is delivery. For human preview, run `officecli watch "$FILE"` (user opens the live preview at their own discretion) or have them open the `.xlsx` directly in Excel / WPS / Numbers.
6. **Print layout fix (wide tables / multi-chart sheets).** When a sheet holds a chart or a wide table and the user will print it, set per-sheet page layout — but match the fit mode to the sheet's height:
```bash
# Short summary / chart sheet → fit to one page.
officecli set "$FILE" "/Summary" --prop orientation=landscape --prop fitToPage=true
# Tall data table → fit width only (fitToPage=true would crush all rows onto one unreadable page).
officecli set "$FILE" "/Data" --prop orientation=landscape --prop fitToPage=1x0
```
Outcome: charts/wide tables print without mid-chart splits; tall tables stay readable across natural page breaks. Apply to every sheet that holds a chart or a > 8-column table.
7. If anything failed, fix, then **rerun the full cycle**. One fix commonly creates another problem.
`officecli view issues` + `view html` are the structural QA pair: `issues` catches broken formulas and empty sheets; `view html` (Read the returned HTML path) catches `###`, truncation, and token leakage. Chart fill colors / theme tints can vary across viewers — spot-check in the user's target viewer when color fidelity matters.
### Formula verification checklist
- [ ] Pick 2-3 formulas at random. Run `officecli get` on each. Confirm the formula string is what you intended **and** `cachedValue=` is what you expect — arithmetic in your head.
- [ ] **Cached value sanity on every summary cell.** Any cell that aggregates (COUNTA / COUNTIF / SUMPRODUCT / INDEX&MATCH) must have a plausible `cachedValue`. If a progress tracker shows `199 / 199 / 100%` on a blank template, the cache is lying — re-touch the formula via `set` (forces recompute) or manually set a correct cached value. Do NOT ship "validate passes but the numbers are fiction".
- [ ] **Spot-check one cell per numeric column.** `%` columns showing integer `0.0%` throughout means the denominator is wrong or the numerator is cached stale — investigate one cell, fix the pattern.
- [ ] Ranges include every row: off-by-one on `SUM(B2:B12)` when data goes to `B13` is the most common bug.
- [ ] Cross-sheet formulas (`Sheet1!A1`) contain no `\!`. If `officecli get` shows `Sheet1\!A1`, the `!` was shell-corrupted — delete and re-enter via batch/heredoc.
- [ ] Named ranges (`officecli get "$FILE" "/namedrange[1]"`) point at what their names claim.
- [ ] Every `/` denominator is guarded — `IFERROR(x/y, 0)` or `IF(y=0, 0, x/y)`.
- [ ] Chart data vs source cells: for every chart with inline data, spot-check data points against `officecli get` of the source cells.
- [ ] Chart title / series name / legend contain **no** unreplaced tokens (`$...$`, `{var}`, `<TODO>`). Grep the chart via `officecli get /Sheet1/chart[N]`.
### Template QA
When editing a template, check for leftover placeholders — they look like content and slip past `validate`:
```bash
officecli query "$FILE" 'cell:contains("{{")'
officecli query "$FILE" 'cell:contains("xxxx")'
officecli query "$FILE" 'cell:contains("TBD")'
```
### Fresh eyes
When you finish a workbook, open it fresh. Read `view text` / HTML preview top-to-bottom as if you are a new reviewer — look for formulas, numbers that look off, formatting inconsistency, missing data.
### Honest limit
`validate` catches schema errors, not design errors. A workbook can pass `validate` with every number wrong. The checklist above — especially spot-checking formulas against source cells — is how you catch what validation can't.
## Known Issues & Pitfalls
### The cross-sheet `!` trap (short)
Shells (bash history expansion, zsh splitting) and CLI arg parsing mangle `!` in `Sheet1!A1` into `\!`. A formula containing `\!` is silently broken — it renders as literal text and references nothing.
**Fix.** Use a batch heredoc with single-quoted delimiter (`<<'EOF'`), which disables all shell expansion:
```bash
cat <<'EOF' | officecli batch "$FILE"
[{"command":"set","path":"/Summary/B2","props":{"formula":"Revenue!B13"}}]
EOF
```
**Verify.** After writing, `officecli get` the cell; `formula=` must show a plain `!` with no backslash.
### CLI bug backlog (short)
CLI constraints and gaps to work around — not defects in the output file.
- **Chart series are immutable after create** — to add/change a series: `remove` + `add` with the full series list. (Position is mutable: `set chart[N] --prop anchor=` / `x/y/width/height`.) `remove chart[N]` shifts subsequent indices down; re-add appends at end.
- **Cross-sheet formula batches run fine through a resident** — a prior "deadlocks even at 3-5 ops" caution no longer reproduces. Pure value-set batches stay reliable at 50-80+ ops too. If you ever hit a hang, fall back to a non-resident one-big-batch or individual `set`. **Multiple resident processes on the same file/machine can still contend** — expect non-deterministic hangs if another agent/session holds a resident on the same file.
- **Conditional formatting naming asymmetry** — the element name for `--type` is `conditionalformatting`; the path suffix is `/cf[N]`. Use `officecli help xlsx conditionalformatting` for schema, `/cf[N]` for paths.
- **Sheet `position` prop on add** — help says Add processes `position`, but the prop is often ignored. Reorder with `officecli move --index` / `--after` / `--before` after creating the sheet.
- **`remove /sheet[N]` cascade guard** — rejects sheet remove/rename when the sheet is referenced by validation / conditional format / sparkline / hyperlink / named range on another sheet. Remove those dependent elements first, then remove the sheet.
- **Batch JSON rejects cell `color` alias** — inside batch `props`, `"color": "FF0000"` errors `ambiguous in cell context — use 'font.color' (text) or 'fill' (bg)`. The CLI at shell level accepts `--prop color=...` / `--prop size=14` as aliases on non-cell elements, but inside batch JSON on a cell always write the full dotted name: `"font.color"`, `"font.size"`, `"font.name"`.
### Renderer caveats (cross-viewer color fidelity)
`officecli view html` is the right tool for structural QA (overflow, truncation, placeholder leakage, layout) — Read the returned HTML path. Some chart rendering details vary across the viewer the end user opens the file in. Observed divergences:
- **Pie / doughnut fill colors may collapse to a single theme tint** in some viewers (slices look "all white" or "all one color"). The file may be fine in the user's target viewer.
- **Line chart / column chart series colors may drift** from the workbook theme in some viewers.
- **Form-control checkboxes may render as double-boxed** in some viewers.
Before calling a color or chart "broken", open the file in the user's actual target viewer. If it looks correct there, the problem is viewer rendering, not data — do not chase it. The CLI's structural checks (`###`, truncation, placeholder text, layout) remain authoritative.
### Escape layers (shell quoting is above; these are the extras)
`$` is the shell layer (single-quote it, above). `\n` / `\t` in a prop value ARE interpreted by the CLI into a real newline / tab. Two more layers:
- **JSON level (batch).** Standard JSON escapes — `"\n"`, `"\t"`, `"\""`. A real backslash in the final string is `"\\\\"`.
- **Excel level.** `\n` in a cell is a real line break — pair with `--prop wrapText=true` so Excel shows the wrap. Works in a shell-quoted prop directly (`--prop value='a\nb'`); `"\n"` inside batch JSON gives the same. When in doubt, `officecli get` the cell and compare character-for-character.
### Other common pitfalls
| Pitfall | Fix |
|---|---|
| `--name "foo"` | All attrs go through `--prop`: `--prop name="foo"` |
| Guessing a prop name | `officecli help xlsx <element>` — don't improvise |
| `--prop color=...` on a cell | Ambiguous — use `font.color` (text) or `fill` (bg). Also applies inside batch JSON: always use full dotted names, never shell aliases |
| `#FF0000` hex colors | Drop the `#`: `FF0000` |
| `--index` vs `[N]` | `--index` is 0-based (array); `[N]` paths are 1-based (XPath) |
| Unquoted `[N]` in zsh/bash | Quote every path: `"/Sheet1/row[1]"` |
| Sheet name with spaces | Quote full path: `"/My Sheet/A1"` |
| Year showing as `2,026` | `--prop type=string` or `numFmt="@"` |
| Modifying a file open in Excel | Close it in Excel first |
| `swap` not reordering sheets | `swap` is for rows/cells. Use `move --after` / `--before` / `--index` for sheets |
| Cached values missing after write | New formulas get cached values when a human opens the file; `validate` accepts them either way |
+417
View File
@@ -0,0 +1,417 @@
---
name: officecli
description: Create, analyze, proofread, and modify Office documents (.docx, .xlsx, .pptx) using the officecli CLI tool. Use when the user wants to create, inspect, check formatting, find issues, add charts, or modify Office documents.
---
# officecli
AI-friendly CLI for .docx, .xlsx, .pptx. Single binary, no dependencies, no Office installation needed.
## Install
If `officecli` is not installed:
```bash
# macOS / Linux
curl -fsSL https://d.officecli.ai/install.sh | bash
# Windows (PowerShell)
irm https://d.officecli.ai/install.ps1 | iex
```
Verify with `officecli --version`. If still not found after install, open a new terminal.
---
## Strategy
**L1 (read) → L2 (DOM edit) → L3 (raw XML)**. Always prefer higher layers. Add `--json` for structured output.
**Before doc work, check Specialized Skills** (bottom of this file). Fundraising decks, academic papers, financial models, dashboards, and Morph animations need their own skill loaded first — `load_skill` once, then proceed.
---
## Help System (IMPORTANT)
**When unsure about property names, value formats, or command syntax, ALWAYS run help instead of guessing.** One help query beats guess-fail-retry loops.
`officecli help` ≡ `officecli --help`, and `officecli <cmd> --help` ≡ `officecli help <cmd>` — same content.
```bash
officecli help # All commands + global options + schema entry points
officecli help docx # List all docx elements
officecli help docx paragraph # Full schema: properties, aliases, examples, readbacks
officecli help docx set paragraph # Verb-filtered: only props usable with `set`
officecli help docx paragraph --json # Structured schema (machine-readable)
```
Format aliases: `word`→`docx`, `excel`→`xlsx`, `ppt`/`powerpoint`→`pptx`. Verbs: `add`, `set`, `get`, `query`, `remove`. MCP exposes the same schema via the single `command` string param: `{"command":"help docx paragraph"}` (not a structured `{"format":...,"type":...}` object — the MCP tool has exactly one param, `command`, and passes it through to the CLI verbatim).
---
## Performance: Resident Mode
**Every command auto-starts a resident on first access** (60s idle timeout) — file-lock conflicts are automatically avoided. Explicit `open`/`close` is still recommended for longer sessions (12min idle):
```bash
officecli open report.docx # explicitly keep in memory
officecli set report.docx ... # no file I/O overhead
officecli close report.docx # save and release
```
Opt out of auto-start: `OFFICECLI_NO_AUTO_RESIDENT=1`.
**Flush only at the non-officecli boundary.** officecli's own reads (`get`/`query`/`view`/`dump`) always see your latest edits, so you never need to save mid-workflow. Run `save` (keeps the resident) or `close` (flush + release) only **before a non-officecli program reads the file** — python-docx/openpyxl, Word, a renderer, delivery/upload. (Idle sessions auto-flush within seconds; `OFFICECLI_RESIDENT_FLUSH=each` makes every mutation flush before returning.)
---
## Quick Start
**PPT:**
```bash
officecli create slides.pptx
officecli add slides.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
officecli add slides.pptx '/slide[1]' --type shape --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm --prop font=Arial --prop size=24 --prop color=FFFFFF
```
**Word:**
```bash
officecli create report.docx
officecli add report.docx /body --type paragraph --prop text="Executive Summary" --prop style=Heading1
officecli add report.docx /body --type paragraph --prop text="Revenue increased by 25% year-over-year."
```
**Excel:**
```bash
officecli create data.xlsx
officecli set data.xlsx /Sheet1/A1 --prop value="Name" --prop bold=true
officecli set data.xlsx /Sheet1/A2 --prop value="Alice"
```
---
## L1: Create, Read & Inspect
```bash
officecli create <file> # Create blank .docx/.xlsx/.pptx (type from extension)
officecli view <file> <mode> # outline | stats | issues | text | annotated | html
officecli get <file> <path> --depth N # Get a node and its children [--json]
officecli query <file> <selector> # CSS-like query
officecli validate <file> # Validate against OpenXML schema
```
### view modes
| Mode | Description | Useful flags |
|------|-------------|-------------|
| `outline` | Document structure | |
| `stats` | Statistics (pages, words, shapes) | |
| `issues` | Formatting/content/structure problems | `--type format\|content\|structure`, `--limit N` |
| `text` | Plain text extraction | `--start N --end N`, `--max-lines N` |
| `annotated` | Text with formatting annotations | |
| `html` | Static HTML snapshot — same renderer as `watch`, no server needed | `--browser`, `--page N` (docx), `--start N --end N` (pptx) |
| `screenshot` / `svg` / `pdf` / `forms` | PNG via headless browser / SVG (pptx slide) / PDF via exporter plugin / form-fields JSON via format-handler plugin | `-o`, `--screenshot-width/-height`, pptx `--grid N` |
Use `view html` for one-shot snapshots (CI artifacts, archival, diffing); use `watch` when you need live refresh or browser-side click-to-select.
### get
Any XML path via element localName. Use `--depth N` to expand children. Add `--json` for structured output. Default text output is grep-friendly: `path (type) "text" key=val key=val ...`
```bash
officecli get report.docx '/body/p[3]' --depth 2 --json
officecli get slides.pptx '/slide[1]' --depth 1 # list all shapes on slide 1
officecli get data.xlsx '/Sheet1/B2' --json
```
### Stable ID Addressing
Elements with stable IDs return `@attr=value` paths instead of positional indices. Prefer these in multi-step workflows — positional indices shift on insert/delete, stable IDs do not.
```
/slide[1]/shape[@id=550950021] # PPT shape
/slide[1]/table[@id=1388430425]/tr[1]/tc[2] # PPT table
/body/p[@paraId=1A2B3C4D] # Word paragraph
/comments/comment[@commentId=1] # Word comment
```
PPT also accepts `@name=` (e.g. `shape[@name=Title 1]`), with morph `!!` prefix awareness. Elements without stable IDs (slide, run, tr/tc, row) fall back to positional indices.
### query
CSS-like selectors: `[attr=value]`, `[attr!=value]`, `[attr~=text]`, `[attr>=value]`, `[attr<=value]`, `:contains("text")`, `:empty`, `:has(formula)`, `:no-alt`. Boolean `and`/`or` supported across `query`/`set`/`remove`: `cell[value>5000 or value<100]`, `cell[(type=Number or type=Date) and value>0]`. Excel row-by-column-name: `Sheet1!row[Salary>5000]`. `set` accepts selectors and Excel-native paths (parity with `get`/`query`). Bare unscoped selectors rejected on `set`/`remove`.
```bash
officecli query report.docx 'paragraph[style=Normal] > run[font!=Arial]'
officecli query slides.pptx 'shape[fill=FF0000]'
```
---
## Watch & Interactive Selection
Live HTML preview that auto-refreshes on every file change. Browsers can click / shift-click / box-drag to select shapes; the CLI can read the current browser selection and act on it.
```bash
officecli watch <file> [--port N] # Start preview server (default port 26315)
officecli unwatch <file> # Stop
officecli goto <file> <path> # Scroll watching browser(s) to element (docx: p / table / tr / tc)
```
Open the printed `http://localhost:N` URL. Click to select; shift/cmd/ctrl+click to multi-select; drag from empty space to box-select. PPT/Word use blue outline; Excel uses native-style green selection (double-click cell to edit inline; drag a chart to reposition).
### `get <file> selected` — read what the user clicked
```bash
officecli get <file> selected [--json]
```
Returns DocumentNodes for whatever is currently selected. Empty result if nothing selected. Exit code != 0 if no watch is running.
```bash
# User clicks shapes in the browser, then asks "make these red"
PATHS=$(officecli get deck.pptx selected --json | jq -r '.data.Results[].path')
for p in $PATHS; do officecli set deck.pptx "$p" --prop fill=FF0000; done
```
### Key properties
- **Selection survives file edits.** Paths use stable `@id=` form.
- **All connected browsers share one selection.** Last-write-wins.
- **Same-file single-watch.** A given file can have only one watch process at a time.
- **Group shapes select as a whole.** Drilling into individual children of a group is not supported in v1.
- **Coverage:** `.pptx` shapes/pictures/tables/charts/connectors/groups; `.docx` top-level paragraphs and tables. Inherited layout/master decorations and Word nested elements (table cells, run-level) are not addressable. **`.xlsx` does not emit `data-path`** — `mark`/`selection` on xlsx always resolve `stale=true` (v2 candidate).
### Marks — edit proposals waiting for review
Use `mark` when changes need human review BEFORE they hit the file. Marks live in the watch process only; a separate `set` pipeline applies accepted ones. For one-shot changes use `set` directly; for permanent file annotations use `add --type comment` (Word native).
```bash
officecli mark <file> <path> [--prop find=... color=... note=... tofix=... regex=true] [--json]
officecli unmark <file> [--path <p> | --all] [--json]
officecli get-marks <file> [--json]
```
Props: `find` (literal or regex when `regex=true`; raw form `find='r"[abc]"'`), `color` (hex / `rgb(...)` / 22 named whitelist), `note`, `tofix` (drives apply pipeline). **Path** must be `data-path` format from watch HTML — see subskills for full pipeline.
---
## L2: DOM Operations
### set — modify properties
```bash
officecli set <file> <path> --prop key=value [--prop ...]
```
**Any XML attribute is settable** via element path (found via `get --depth N`) — even attributes not currently present. Without `find=`, `set` applies format to the entire element.
**Value formats:**
| Type | Format | Examples |
|------|--------|---------|
| Colors | Hex (with/without `#`), named, RGB, theme | `FF0000`, `#FF0000`, `red`, `rgb(255,0,0)`, `accent1`..`accent6` |
| Spacing | Unit-qualified | `12pt`, `0.5cm`, `1.5x`, `150%` |
| Dimensions | EMU or suffixed | `914400`, `2.54cm`, `1in`, `72pt`, `96px` |
**Dotted-attr aliases** — `font.<attr>` forms accepted on shape/run/paragraph/table/row/cell/section/styles, e.g. `--prop font.color=red --prop font.bold=true --prop font.size=14pt`. Run `officecli help <fmt> <element>` for the full list.
### find — format or replace matched text
Use top-level `--find` / `--replace` on `set` (and `--find` on `query`). Legacy `--prop find=X` still works but emits a hint.
```bash
# Format matched text (auto-splits runs)
officecli set doc.docx '/body/p[1]' --find weather --prop bold=true --prop color=red
# Regex matching (regex= still a prop flag)
officecli set doc.docx '/body/p[1]' --find '\d+%' --prop regex=true --prop color=red
# Replace text (use `/` for whole-document scope)
officecli set doc.docx / --find draft --replace final
# docx: tracked Find&Replace
officecli set doc.docx / --find draft --replace final --prop revision.author=Alice
# PPT — same syntax, different paths
officecli set slides.pptx / --find draft --replace final
```
**Path controls search scope:** `/` = whole document, `/body/p[1]` or `/slide[N]/shape[M]` = specific element, `/header[1]` / `/footer[1]` = headers/footers.
**Notes:**
- Case-sensitive by default. Case-insensitive: `--prop 'find=(?i)error' --prop regex=true`
- Matches work across run boundaries
- No match = silent success. `--json` includes `"matched": N`
- **Excel:** only `find` + `replace` supported (no find + format props)
### add — add elements or clone
```bash
officecli add <file> <parent> --type <type> [--prop ...]
officecli add <file> <parent> --type <type> --after <path> [--prop ...] # insert after anchor
officecli add <file> <parent> --type <type> --before <path> [--prop ...] # insert before anchor
officecli add <file> <parent> --type <type> --index N [--prop ...] # 0-based position (legacy)
officecli add <file> <parent> --from <path> # clone existing element
```
`--after`, `--before`, `--index` are mutually exclusive. No position flag = append to end.
**Element types (with aliases):**
| Format | Types |
|--------|-------|
| **pptx** | slide (incl. hidden), shape (font.latin/ea/cs, direction=rtl, underline.color, highlight=COLOR (Add/Set/Get/HTML preview), effective.X+effective.X.src; arrow alias for rightArrow; slideMaster/slideLayout typed add/set/remove), picture (SVG, brightness/contrast/glow/shadow, rotation, link, tooltip), chart (direction=rtl, pieOfPie, barOfPie, axisLine/gridline per-attr setters, animation+chartBuild=byCategory|bySeries, line dropLines/hiLowLines/upDownBars, anchor=x,y,w,h shorthand), table (cell direction=rtl, fill/background, built-in PowerPoint style catalogue, /col[C] get + swap/copyFrom, row/col Move/CopyFrom), row (tr), connector (from/to accept full-path `@name=`/`@id=` forms — bare `@name=Foo` is rejected, must be `/slide[N]/shape[@name=Foo]` — startshape/endshape SetByPath; edge-to-edge anchoring by default, fromSide/toSide to force an edge, fromIdx/toIdx for raw cxn index), group (link, tooltip, deep walk by get/query/add/remove, ungroup=true dissolves back to slide-absolute), align/distribute (targets= accepts shape[@id=N] paths, not just positional), video/audio (loop, autoStart alias), equation, notes (direction=rtl, lang), comment (legacy + modern p188 threaded round-trip), animation (15 emphasis + 16 exit presets, multi-effect chains, motion-path presets, repeat/restart/autoReverse, chart animations), transition (12 p15 presets + morph/p14), paragraph (para), run, zoom, ole (preview=, full dump round-trip via add-part+raw-set), placeholder (phType=...), model3d (rotation=ax,ay,az; full dump round-trip), smartart (dump round-trip via add-part), diagram (add-only mermaid → native shapes or rendered image, `--type diagram`/`flowchart`). |
| **docx** | paragraph (direction/font.latin/ea/cs, bold.cs/italic.cs/size.cs, lang.latin/ea/cs, wordWrap, framePr.\*, tabs shorthand), run (lang slots, direction, underline.color, position half-pts, **revision.type=ins\|del\|format\|moveFrom\|moveTo + revision.action=accept\|reject** with .author/.date — bare `@author=`/`@type=` selector on `set /revision[...]` for filtered accept/reject, but `query 'revision[...]'` needs the dotted `revision.author=`/`revision.type=` form; move+revision is run-level paths only, not paragraph-level; **range=START:END** on a paragraph/shape path formats a char span by explicit 0-based half-open offset instead of addressing a run — the offset sibling of find=), table (direction=rtl, hMerge, cantSplit on row/nowrap on cell (both add+set), **virtual column ops**: add/remove/move/copyfrom on /body/tbl[N]/col), row (tr), cell (td), image, header/footer (direction), section (pageNumFmt full enum, direction=rtl, rtlGutter, pgBorders=box), bookmark, comment, footnote, endnote, formfield, sdt, chart, equation, field (28 types), hyperlink, style (direction, indents, pbdr, lineSpacing on Add/Set), toc, watermark, break, ole, **num/abstractNum/lvl**, **tab**, **textbox/shape** (add-mostly — Get returns raw XML preview only, no structured readback; Set is limited to width/height/geometry/fill/line.\*; position is `anchor.x`/`anchor.y` not bare x/y; **textbox-only** `textDirection`/rotation/gradient/shadow — docx shape itself has neither rotation nor gradient), embedded **OLE round-trip on dump→batch**, **diagram** (add-only mermaid → native shapes or rendered image, `--type diagram`/`flowchart`, no x/y at add-time — reposition via `set /body/group[N]`). docDefaults.rtl, autoHyphenation, `get /` exposes locale + /comments /footnotes /endnotes. `create --minimal` for raw OOXML scaffolding. |
| **xlsx** | sheet (visible/hidden/veryHidden, print margins, printTitleRows/Cols, rightToLeft sheetView, cascade-aware rename), row (c{N}= cell-content shorthand; add accepts --from /Sheet/col[L]; formula-ref rewrite on insert), col (formula-ref rewrite, named-range follow on move), cell (type=richtext+runs, merge=range/sweep, direction=rtl, phonetic; **--shift left\|up on remove, shift=right\|down on add** — Excel UI dialog parity; formula auto-detect; OFFSET/INDIRECT in calc), chart (per-axis RTL/title, anchor=x,y,w,h, pareto), image (SVG), comment (direction=rtl), table (listobject), namedrange (definedname, volatile, `[@name=X]`; formula-body inlined at parse), pivottable (cache CoW + cross-pivot sharing, labelFilter=field:type:value add-time-only, topN=integer add-time-only, fillDownLabels is an alias of repeatLabels not a separate feature, calculatedField), sparkline, validation, autofilter, shape, textbox, CF (databar/colorscale/iconset/formulacf/cellIs/topN/aboveAverage), ole, csv. Query supports `merge`/`mergedrange`. Workbook: password. Shape selector enumerates leaves inside grpSp. |
### Pivot tables (xlsx)
```bash
officecli add data.xlsx /Sheet1 --type pivottable \
--prop source="Sheet1!A1:E100" --prop rows=Region,Category \
--prop cols=Year --prop values="Sales:sum,Qty:count" \
--prop grandTotals=rows --prop subtotals=off --prop sort=asc
```
Key props: `rows`, `cols`, `values` (Field:func[:showDataAs]), `filters`, `source`, `position`, `layout` (compact/outline/tabular), `repeatLabels`, `blankRows`, `aggregate`, `showDataAs` (percent_of_total/row/col, running_total), `grandTotals`, `subtotals`, `sort`. Aggregators: sum, count, average, max, min, product, stdDev, stdDevp, var, varp, countNums. Date columns auto-group. Run `officecli help xlsx pivottable` for full schema.
### Document-level properties (all formats)
```bash
officecli set doc.docx / --prop docDefaults.font=Arial --prop docDefaults.fontSize=11pt
officecli set doc.docx / --prop protection=forms --prop evenAndOddHeaders=true
officecli set data.xlsx / --prop calc.mode=manual --prop calc.refMode=r1c1
officecli set slides.pptx / --prop defaultFont=Arial --prop show.loop=true --prop print.what=handouts
```
Run `officecli help <format> /` for all document-level properties (docDefaults, docGrid, CJK spacing, calc, print, show, theme, extended).
### Sort (xlsx)
```bash
officecli set data.xlsx /Sheet1 --prop sort="C desc" --prop sortHeader=true
officecli set data.xlsx '/Sheet1/A1:D100' --prop sort="A asc" --prop sortHeader=true
```
Format: `COL DIR[, COL DIR ...]`. Rejects ranges with merged cells or formulas. Sidecar metadata (hyperlinks, comments, conditional formatting, drawings) follows rows automatically.
### Text-anchored insert (`--after find:X` / `--before find:X`)
Locate an insertion point by text match within a paragraph. Inline types (run, picture, hyperlink) insert within the paragraph; block types (table, paragraph) auto-split it. PPT only supports inline.
```bash
# Word: inline run after matched text
officecli add doc.docx '/body/p[1]' --type run --after find:weather --prop text=" (sunny)"
# Word: block table after matched text (auto-splits paragraph)
officecli add doc.docx '/body/p[1]' --type table --after "find:First sentence." --prop rows=2 --prop cols=2
```
### Clone
`officecli add <file> / --from '/slide[1]'` — copies with all cross-part relationships.
### move, swap, remove
```bash
officecli move <file> <path> [--to <parent>] [--index N] [--after <path>] [--before <path>]
officecli swap <file> <path1> <path2>
officecli remove <file> '/body/p[4]'
```
When using `--after` or `--before`, `--to` can be omitted — the target container is inferred from the anchor.
### batch — multiple operations in one save cycle
**Atomic by default (v1.0.137+):** every item still runs and is reported (so `N succeeded, M failed` stays meaningful and every failure surfaces), but if *any* item fails the whole batch rolls back — the file on disk is left byte-identical to before the batch ran (confirmed live in both standalone and resident mode). Use `--best-effort` to restore the old apply-what-succeeds behavior (useful for lossy `dump→batch` replays where losing the whole thing over one unsupported item is worse than a partial result). `--stop-on-error` only changes how early the run stops (remaining items are `skipped`), not whether what ran gets kept — combine it with `--best-effort` if you want "stop at first failure but keep what already succeeded." `--force` is unrelated — it's only the docx-protection bypass. Failed items carry a machine-readable `code` field (same list as `error.code`); a rolled-back batch's JSON summary carries `"atomicRolledBack": true`.
`officecli dump <file> [<path>]` emits a replayable batch JSON for round-trip — `.docx` (full coverage), `.pptx` (text/tables/pictures/charts/notes/theme + OLE/3D/video/audio/SmartArt/morph/p15 transitions via raw-set passthrough), and `.xlsx` (cells/formulas/styles + tables, conditional formatting, validations, comments, charts, sparklines, pictures, shapes, pivot tables; slicers/chartEx/OLE via verbatim carrier). Path defaults to `/` (whole document); pass a subtree path (docx: `/body`, `/body/p[N]`, `/body/tbl[N]`, `/theme`, `/settings`, `/numbering`, `/styles`; xlsx: `/SheetName`, `/sheet[N]`) to scope the dump. `officecli refresh <file.docx>` recalculates TOC page numbers / PAGE / cross-references after replay (Word backend on Windows; headless-HTML fallback elsewhere). `officecli plugins list` extends support to `.doc`, `.hwpx`, `.pdf` export.
```bash
echo '[
{"command":"set","path":"/Sheet1/A1","props":{"value":"Name","bold":"true"}},
{"command":"set","path":"/Sheet1/B1","props":{"value":"Score","bold":"true"}}
]' | officecli batch data.xlsx --json
officecli batch data.xlsx --commands '[{"op":"set","path":"/Sheet1/A1","props":{"value":"Done"}}]' --json
officecli batch data.xlsx --input updates.json --best-effort --json # keep whatever succeeds even if some items fail
```
Supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`. Fields: `command` (or `op`), `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props`, `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
---
## L3: Raw XML
Use when L2 cannot express what you need. No xmlns declarations needed — prefixes auto-registered.
```bash
officecli raw <file> <part> # view raw XML
officecli raw-set <file> <part> --xpath "..." --action replace --xml '<w:p>...</w:p>'
officecli add-part <file> <parent> # create new document part (returns rId)
```
`raw-set` actions: `append`, `prepend`, `insertbefore`, `insertafter`, `replace`, `remove`, `setattr`. Run `officecli help <format> raw` for available parts.
---
## Common Pitfalls
| Pitfall | Correct Approach |
|---------|-----------------|
| `--name "foo"` | Use `--prop name="foo"` — all attributes go through `--prop` |
| Unquoted `[N]` paths in zsh/bash | Always quote: `'/slide[1]'` or `"/slide[1]"` (shell glob-expands brackets) |
| PPT `shape[1]` for content | `shape[1]` is typically the title placeholder. Use `shape[2]+` for content shapes |
| `/shape[myname]` | Name indexing not supported. Use numeric index or `@name=` (PPT only) |
| Guessing property names | Run `officecli help <format> <element>` to see exact names |
| Modifying an open file | Close the file in PowerPoint/WPS first |
| `\n` in shell strings | Use `\\n` for newlines in `--prop text="..."` |
| `$` in shell text | `--prop text="$15M"` strips `$15`. Use single quotes: `--prop text='$15M'`, or heredoc batch |
---
## Specialized Skills
`officecli load_skill <name>` — output is a SKILL.md, follow its rules.
**Loading rule**:
- Pick the most specific match in "When to use"; if none fits, load the format default (`word` / `pptx` / `excel`).
- Scenes already contain the format default's rules — load **one** skill per artifact, never stack.
- Loaded rules persist across turns; don't re-load each reply.
- Two distinct artifacts → two separate loads.
### Word (.docx)
| Name | When to use |
|------|-------------|
| `word` | Reports, letters, memos, proposals, generic documents |
| `academic-paper` | Journal / conference / thesis: APA / Chicago / IEEE / MLA citations, equations, SEQ + PAGEREF cross-refs, multi-column journal layout, bibliography. NOT for business reports or letters (route those to `word`) |
### PowerPoint (.pptx)
| Name | When to use |
|------|-------------|
| `pptx` | Generic decks: board reviews, sales decks, all-hands, product launches |
| `pitch-deck` | **Fundraising only** — seed / Series A-C / SAFE / convertible / strategic raise. NOT for sales / product / board decks (route those to `pptx`) |
| `morph-ppt` | Cinematic Morph-animated presentations. NOT for static decks (route those to `pptx`) |
| `morph-ppt-3d` | 3D Morph: GLB models, camera moves, depth. NOT for 2D-only Morph (route those to `morph-ppt`) |
### Excel (.xlsx)
| Name | When to use |
|------|-------------|
| `excel` | Generic workbooks, formulas, pivots, trackers |
| `financial-model` | Financial models, scenarios, projections. NOT for general data analysis (route those to `excel`) |
| `data-dashboard` | CSV/tabular data → KPI / analytics / executive dashboards with charts and sparklines. NOT for raw data tracking (route those to `excel`) |
Example: a fundraising deck task → `officecli load_skill pitch-deck` → use the printed rules.
---
## Notes
- Paths are **1-based** (XPath convention): `'/body/p[3]'` = third paragraph
- `--index` is **0-based** (array convention): `--index 0` = first position
- **Excel exception**: for `add --type row` and `add --type col`, `--index N` is **1-based** (matches OOXML RowIndex / column letter index). `--index 5` inserts at row 5 / column 5.
- After modifications, verify with `validate` and/or `view issues`
- **When unsure**, run `officecli help <format> <element>` instead of guessing
+187
View File
@@ -0,0 +1,187 @@
---
name: bundle-weaver
description: >-
Fold event analyses into an OKF knowledge bundle in deterministic sequence. This is the
only agent that writes `.okf/`, executing the reduce phase of backfill replay to evolve
concepts over time, enforce anti-degeneration rules, and guarantee no events are lost.
model: sonnet
effort: high
tools: Read, Write, Edit, Bash, Grep, Glob
color: green
---
You are the semantic weaver for OKF bundle reconstruction. Your task is to take the analyses
produced by `okf:event-analyzer` (one per event, in `analyses/` directory) and fold them into
the `.okf/` bundle in deterministic order. You are the only agent that writes to `.okf/`; your
decisions on concept naming, grouping, and log entries become the bundle's permanent record.
## Input you receive
- A `.okf/` bundle directory (may be empty or partially populated from a prior resume).
- An `analyses/` directory with one `.md` file per event, timestamped in frontmatter. File names
are event ids (content hashes) and carry no order.
- A cursor state file (`.okf/.backfill-state.json`) indicating which events have already been
folded (resume case).
- A list of "live" event ids (those without a `skip` field) to process in order.
- Each analysis carries a `truncated` flag in its frontmatter: `true` means the analyzer saw a
capped diff. Treat that rationale as partial evidence (lean even harder toward update over
create) and count it for your reply.
## Your responsibilities
### 1. Process events in order
Read analyses in chronological order: follow the live-event-id list you were given (it carries
the event-stream order), or sort by each analysis's frontmatter `timestamp`. Do NOT sort by
filename: event ids are content hashes and their lexical order is unrelated to time.
For each event:
1. Load the analysis file.
2. Extract candidate concept names and rationale.
3. Decide: update existing concepts, or create new ones (prefer update).
4. For each touched concept: append source reference (the event id, with `:` sanitized to `-`
in the filename). Every live event lands in at least one concept's `sources`: that, not
`log.md`, is what the coverage check relies on.
5. If the event created, deprecated or superseded a concept, add a `log.md` bullet under the
event's date (§4). A routine update gets no bullet: its "why" goes into the concept body.
6. Update cursor: save `event_id` and increment `done` count.
7. Repeat.
### 2. Concept lifecycle rules
- **Name by domain entity, not by change.** Existing concept names are canonical; candidate names
from analyses are suggestions. If an analysis proposes `cache-invalidation` and `cache-invalidation.md`
already exists, update it. If it proposes `invalidation-strategy` and `cache-invalidation` exists,
check if the analysis belongs in the existing concept (usually yes); only create `invalidation-strategy`
if its scope is truly distinct.
- **1–3 concepts per event is typical.** If an analysis touches many subsystems, group them into
higher-level concepts rather than creating one per subsystem.
- **Update > Create.** The bundle evolves, not explodes. Each concept's body grows with sources and
discoveries; the frontmatter's `sources` section records all events that touched it.
- **A reversal is not growth.** Events arrive in chronological order, so a later one can overturn
what an earlier one established: a phase dropped, a limit changed, an option moved in or out of
scope, a target downgraded. Growth appends; a reversal must replace, or the bundle ends up
asserting both states in the present tense. Before writing, `grep` the whole bundle (every
directory, not the one you are about to write in) for the candidate's distinctive tokens and
reread every hit. Then:
- The analysis contradicts the concept you are updating → rewrite the body to what holds today,
and move the previous position to a dated line under a `## History` section ("2026-08-24: was
X, replaced by Y").
- The new state already lives in a *different* concept → the outdated one gets
`status: deprecated` and a link to its successor, keeping its body and all its `sources` (so
the coverage check stays green). Note both sides in the log bullet.
- Contradiction you cannot resolve from the evidence → keep the newer state and mark the older
line `[UNCLEAR]` rather than leaving two present-tense claims standing.
Count each resolution for `superseded` in your reply.
### 3. Frontmatter contract for concepts
Each `.okf/*/*.md` file must have YAML frontmatter:
```yaml
type: <inferred from analysis; one of: skill, plugin, tool, decision, pattern, architecture>
title: <Human-readable title>
description: <One-liner for the concept's essence>
status: draft # or `deprecated` once superseded (§2); never `stable`
tags: [tag1, tag2]
generated:
by: okf-backfill/0.9.5
at: <ISO 8601 timestamp now>
sources:
- id: git-abc1234 # Sanitized event id (colons → dashes)
resource: git:abc1234 # Original id for reverse lookup
last_modified: <the event's timestamp, ISO 8601 with offset, as in events.jsonl>
- id: session-file-42
resource: session:file:42
last_modified: <the event's timestamp, ISO 8601 with offset, as in events.jsonl>
```
**`status` is always written, and never `stable`.** SPEC §5.4 reads an absent `status` as
`stable`, "ready for consumption", and defines `draft` as "not yet reviewed; possibly
incomplete" — the literal description of a machine-replayed history. Every concept you write is
`draft` until a human reviews it; the only other value you may write is `deprecated`, per §2.
**For session events, the `resource` and `id` differ:** `session:<file>:<lineno>` is the resource;
`session-<file>-<lineno>` is the sanitized id (all `:` → `-`).
### 4. Log entry format
`log.md` records lifecycle events only, not every update: a concept **created**, a concept
**deprecated**, one concept **superseding** another. The date heading is the event's date, not
today's. Format:
```
## 2026-09-01
- **Creation**: [Presales pipeline](/concepts/sales/presales-pipeline.md), the sales workflow
formalized to clarify handoff points (from session: "Discuss..."). Sources: `git-abc1234`
- **Supersession**: [Vendor sync](/concepts/sales/vendor-sync.md) supersedes
[Manual vendor import](/concepts/sales/manual-vendor-import.md), now `deprecated`. Sources: `git-def5678`
```
Rules:
- Link to the concept file with Markdown syntax.
- Explain the "why" from the analysis, not just what changed.
- Never a bare restatement of the commit subject (that's the analysis's job; here you explain intent).
- A routine update, growth or a reversal inside one concept gets no bullet: the body carries
today's state, `## History` carries what it replaced, `sources` carries the events.
### 5. Anti-degeneration enforcement
Before finalizing each concept, run these checks:
- **No change-derived names:** filenames must not match `merge-pull-request-*`, `feat:-*`, `fix:-*`, etc.
- **Kebab-case only:** `[a-z0-9-]` in filenames (no spaces, underscores, capitals, or special chars).
- **Concepts must be entities, not actions.** A concept represents "the thing", not "the change to the thing".
If an analysis proposes a violating name, keep it out of the bundle and note the conflict in the log
(as a comment, or a separate line marked `[CONFLICT]` if the rule is unclear).
### 6. Cursor and resume
After each event:
```json
{"last_id": "git-abc1234", "done": 42}
```
If the job crashes or resumes, start from `last_id` + 1. The cursor is your audit trail: it records
how far you got and enables recovery without re-analyzing or re-writing.
### 7. Directory structure
Create a hierarchy based on the domain you infer from concepts:
- `skills/` for skills (backfill, validate, etc.)
- `decisions/` for architectural choices
- `components/` for technical components
- `integrations/` or `services/` for external integrations
- `tools/` for utilities
If unsure, default to `concepts/`.
## Output signature
When done with all live events (or a batch, if resuming):
- `.okf/<dir>/<concept>.md` files created/updated with sources and frontmatter.
- `.okf/log.md` updated with dated lifecycle bullets (creations, deprecations, supersessions).
- `.okf/.backfill-state.json` cursor updated.
The bundle is production-ready after the orchestrator runs the finalize step (validator + coverage check).
## What you return
Your reply to the orchestrator is one line of counts and nothing else:
```
folded=<n> created=<n> updated=<n> bullets=<n> conflicts=<n> superseded=<n> truncated_inputs=<n> last_id=<event-id>
```
The bundle is on disk; never paste concept bodies, log sections, or analyses into the reply.
## Hard rules
- **Only you touch `.okf/`.** If you see modifications from earlier runs, they are canonical;
don't replay changes to concepts that already exist (compare `last_modified` in sources and
skip if you're re-analyzing the same event).
- **Atomic per event:** update multiple concepts, log once, save cursor once per event.
- **No external reasoning:** the bundle is built on the analyses you receive. Don't invent
concepts or connections not supported by the event record.
+144
View File
@@ -0,0 +1,144 @@
---
name: event-analyzer
description: >-
Analyze a single repository history event (git commit or session turn) to extract domain
concepts and semantic content. Use in parallel during the map phase of OKF backfill replay
to materialize decision rationale from raw commit diffs and session outcomes.
# Bulk-read worker: it moves evidence into structure, it does not judge the bundle.
# Retier by forking this file; the backfill skill resolves the agent by name.
# haiku since benchmark/map-tier/RESULTS.md: parity with sonnet on commits at under half the cost,
# on the condition that the two explicit instructions below (flag source, evidence-only claims) stay.
model: haiku
effort: medium
tools: Bash, Read, Write
color: blue
---
You are a semantic analyzer for repository history events. You examine one git commit or
Claude session turn (or a small batch of them) and extract the domain concepts it touches, the
rationale behind the change, and candidate names for OKF knowledge concepts. You are a bulk
reader: you move evidence into structure. Judgment about the bundle (naming, merging, what to
keep) belongs to the weaver, not to you.
## Input you receive
- One or more event ids, and `events`: the path of `events.jsonl`. Fetch each event yourself,
one call per id (the orchestrator never reads event content):
```bash
jq -c --arg id '<id>' 'select(.id==$id)' <events>
```
Each event is a JSON object with:
- `id`: unique event identifier (e.g., `git:abc1234` or `session:file.jsonl:42`)
- `source`: either `git` or `session`
- git events: `sha`, `subject`, `body`, `author`, `files` (list of `{path, add, del}`)
- session events: `user` (question/prompt), `outcome` (assistant wrap-up), `title`
- `ts`: ISO 8601 timestamp
- `repo`: the repository root path.
- `out`: the directory where you write analyses.
- `emitter`: the path of the diff emitter script (`okf_backfill_events.py`), plus any
`--skip-globs` the orchestrator wants applied.
## How to read the evidence
- **Git commits**: the subject is the claim, the diff is the evidence. Read the diff **only**
through the emitter:
```bash
uv run <emitter> <repo> --show <sha> [--skip-globs ...]
```
The emitter prints the complete stat (every file, with line counts), then the patches capped
per file and cut only at hunk or file boundaries, with a bracketed marker at every cut, and a
fixed last line: `[diff: shown=X total=Y files_shown=A files_total=B truncated=true|false]`.
- Never run a raw `git show <sha>`: the harness would cut it blindly, mid-hunk.
- If `truncated=true` and the stat points at one file whose patch you need for the rationale,
you may make **one** follow-up call: `uv run <emitter> <repo> --show <sha> --only <path>`.
That is the only permitted escalation; then write what you have.
- **Session turns**: the `user` text and the `outcome` text are the whole semantic unit. The
jq fetch is the only tool call they need; do not go looking for more.
Extract, per event:
1. **Claim**: what the event asserts or achieves.
2. **Entities**: which domain concepts, subsystems, files, or features it touches. Name only
paths and entities that appear in this event's own evidence.
3. **Rationale**: why, from the commit body, session context, or diff evidence.
4. **Candidate concepts**: kebab-case concept names (`presales-pipeline`, `vendor-sync`,
`cache-invalidation`; never `feat:-add-feature` or `merge-pull-request-...`). Suggestions
only; the weaver decides.
## Anti-degeneration rules
- **Concept names describe the domain entity, not the change type.** Forbidden:
- Names derived from a git subject (`feat:`, `fix:`, `chore:`, `merge-pull-request-...`)
- Non-kebab-case (allowed: `[a-z0-9-]` only; no spaces, underscores, capitals)
- Generic placeholders (`changes`, `update`, `task`)
- **Every candidate name justifies its existence.** Three subsystems touched, three candidates,
each with a phrase saying which subsystem it covers.
## Output format
One file per event: `<out>/<event-id>.md`, where `event-id` is the id with every `:` replaced
by `-` (`git-abc1234.md`, `session-file.jsonl-42.md`). Exactly this structure, nothing outside
it (no greeting, no preamble, no commentary, no restating the diff):
```markdown
---
event_id: <original id with colons>
source: git|session
timestamp: <ISO 8601>
truncated: true|false
---
# Claim
- <one sentence: the essential change or outcome>
# Entities
- <path or entity>: <its role in this event>
# Rationale
- <why, in one sentence>
- <a second bullet only if the evidence supports a distinct reason>
# Candidate concepts
- `concept-name`: <domain it represents>; <one phrase on why it exists>
```
- `truncated` copies the last line of the **first** `--show` call for the commit: `true` if it
said `truncated=true`; `false` for complete diffs and for session events. A `--only`
follow-up never changes it: its own summary line describes one file, not the commit.
- Bullets only, one line each, leading with a name or a path. Rationale is at most two bullets.
- The claim states what the evidence shows happened, nothing more. A session outcome that only
shows intent or a first step is reported as intent or a first step; never extend it to what
probably happened next. When the evidence does not settle the why, write `[UNCLEAR]` in the
bullet instead of a plausible guess; the weaver routes it.
- When the prompt carries several events, write one file per event, each from its own evidence
only. Never let one event's content leak into another's analysis.
## What you return
Your reply to the orchestrator is **one line per event** and nothing else:
```
<out>/<event-id>.md candidates=<n> truncated=<true|false>
```
The analysis is on disk; the reply carries counts. If an event could not be analyzed, the line
is `<event-id> FAILED: <one-phrase reason>`.
## Hard constraints
- **Never modify `.okf/`**: extraction only. The weaver is the only actor that writes the bundle.
- **No external knowledge**: describe what the evidence shows, never what you know from elsewhere.
- **No raw diffs**: the emitter and its `--only` follow-up are the only ways to read a commit.
## Notes on reading a diff
- `-` lines were removed, `+` lines replaced them. A hunk the emitter cut is marked; do not
guess what the missing part did.
- Filenames tell you the scope; hunks tell you the intent.
+338
View File
@@ -0,0 +1,338 @@
---
name: backfill
description: >-
Reconstruct an OKF bundle by event-sourcing a repository's history (git log
and Claude session transcripts). Use when creating an `.okf/` bundle for an
existing repository that predates this skill, or when resuming an interrupted
backfill session. Triggers on: "reconstruct the OKF bundle", "backfill the
knowledge bundle", "event-source the history".
user-invocable: true
argument-hint: "[repo-dir] [--no-sessions] [--sessions-dir DIR]"
allowed-tools: Bash Read Write Edit
---
# Reconstruct an OKF bundle from history
This skill replays a repository's decision-making history (git commits and
Claude session transcripts) to rebuild its OKF knowledge bundle as if the
[okf](/okf) skill's Stop hook had been active from the start.
The extraction layer is **deterministic** (same repo → byte-identical events);
the replay layer is an LLM loop, **replayable and auditable but not
byte-identical** (timestamps, summaries change per run). See the event schema
(§1) and skip rules (§3).
## 1. Event schema
Git commits and session turns become events (JSONL, one per line, sorted by
timestamp then source):
```json
{"id":"git:<sha>","source":"git","ts":"<ISO8601 Z>","sha":"...","author":"...","subject":"...","body":"...","files":[{"path":"...","add":N,"del":N}]}
{"id":"session:<file>:<lineno>","source":"session","ts":"<ISO8601 Z>","user":"...","outcome":"...","title":"...","branch":"...","skip":"..."}
```
- **Git events** come from `git log --first-parent --numstat`.
- **Session events** pair each user message with the **last** assistant text
block of that turn (the wrap-up), extracted from
`~/.claude/projects/<repo-slug>/*.jsonl` and worktree subdirs.
- **Timestamps** are normalized to UTC ISO 8601 strings ending in `Z`.
- **Skip field** (optional, added by extraction): marks low-signal events —
see §3, never overridden by replay.
## 2. Protocol: Preflight → Extract → Bootstrap → Replay (Map+Reduce) → Finalize
### Preflight: check if bundle can be rebuilt
```bash
if [ -d <repo>/.okf ] && ! [ -f <repo>/.okf/.backfill-state.json ]; then
echo "ERROR: .okf/ exists but is incomplete. Delete it to rebuild from scratch, or pass --resume to continue from the last checkpoint."
exit 1
fi
```
If `.backfill-state.json` exists, resume from the cursor; otherwise, fresh bootstrap.
### Extract: generate event stream
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_backfill_events.py" <repo-dir> \
--out events.jsonl \
[--no-sessions] [--sessions-dir ~/.claude/projects] \
[--max-text 2000] [--skip-globs "vendor/**"]
```
Write `events.jsonl` to the scratchpad (never committed). Report:
- Total events extracted, per-source counts, and per-rule skip counts.
### Bootstrap: initialize bundle (fresh only)
Create `.okf/` with:
```bash
mkdir -p <repo>/.okf
echo 'okf_version: "0.2"' > <repo>/.okf/index.md
echo '# Update Log' > <repo>/.okf/log.md
```
Cursor (`.okf/.backfill-state.json`):
```json
{"last_id": null, "done": 0}
```
### Replay: two-phase map/reduce protocol
The replay is now structured in two phases to enforce semantic depth and anti-degeneration rules:
#### Phase 1: Map (parallel analysis, per-event)
Launch `okf:event-analyzer` agents for the live events (those without `skip` field), in waves of
4 to 10 (see the cost note in §5), via Claude Code's Workflow `agentType` parameter. Each analyzer
receives its event ids, the `events.jsonl` path, the repo path, the output directory and the
emitter path, and:
- Fetches its own event JSON with `jq` (the orchestrator dispatches ids, never content)
- Reads git evidence only through the capped diff emitter (§6); session events need no other call
- Writes `analyses/<event-id>.md` (event id with `:` sanitized to `-`) to scratchpad, with a
`truncated` flag copied from the emitter's last line
- Replies with one line of counts per event; the analysis never travels back in the reply
- Never touches `.okf/`
- Is resumable: skip events already in `analyses/`
**Dispatch rule** (deterministic, from `events.jsonl`, no content read): a live event is
*small* when it is a session turn or a commit whose numstat totals at most 60 changed lines.
Small events are grouped chronologically, eight per analyzer call; large commits go one per
call. Measured on this repository (`benchmark/map-tier/RESULTS.md`): 57 calls instead of 147,
a third off the map phase at the same tier, no loss on any per-event metric and no
cross-event contamination.
```bash
jq -c 'select(.skip==null) | {id, small: (.source=="session" or (([.files[]?|.add+.del]|add)//0) <= 60)}' events.jsonl
```
**Host-agnostic fallback** (if no Workflow support): spawn generic subagents with the same
system prompt as `agents/event-analyzer.md`, one per event, collecting analyses to scratchpad.
#### Phase 2: Reduce (sequential folding, one agent)
Run a single `okf:bundle-weaver` agent that:
- Reads all analyses in chronological order
- Folds them into `.okf/`, updating or creating concepts
- Enforces anti-degeneration rules (§4)
- Adds a dated `log.md` bullet for each lifecycle event only (a concept created, deprecated or
superseded); the "why" of a routine update goes into the concept body
- Manages cursor (`.okf/.backfill-state.json`) for resume capability
- Is the only actor that writes `.okf/`
- Replies with one line of counts (folded, created, updated, bullets, conflicts, superseded,
truncated inputs); the bundle never travels back in the reply
**Resume behavior:** both phases support resumption. Phase 1 skips already-analyzed event ids;
Phase 2 restarts from `last_id` in the cursor.
### Domain priming (advisory)
Before starting the map phase, read the repository's README and directory structure to sketch
a candidate taxonomy of concepts (skills, integrations, decisions, etc.). This priming is
*advisory only* — it helps the analyzer emit better candidate names. The *guarantee* that
no events are lost comes from the deterministic coverage check in Finalize (§5), not from
priming; analyses without candidate names still flow to the weaver.
### Context hygiene (orchestrator)
The orchestrator handles ids and counts, never content. Events, analyses and diffs are read by
the agents; the orchestrator reads:
```bash
wc -l events.jsonl # total events
jq -r 'select(.skip==null) | .id' events.jsonl # live ids to dispatch
ls analyses | wc -l # analyses written
```
Never `cat events.jsonl` or open an analysis from the orchestrator: whatever enters its context
is billed at the frontier rate on every following turn, and the routing exists to prevent that.
### Finalize: validate and clean up
1. **Write `index.md` per directory by hand** (do NOT run `okf_init.py` — it
would scaffold placeholder files): one `# Section` per directory, one
bullet per concept, `* [Title](file.md) - description` taken from each
concept's frontmatter (SPEC §8). The root `index.md` keeps its
`okf_version: "0.2"` frontmatter and links every subdirectory.
2. **Add final log entry** (today's date, appended to existing dated section if present):
```
## 2026-09-01
- **Backfill**: reconstructed from N events by okf-backfill/0.9.5
```
3. **Delete cursor:**
```bash
rm -f .okf/.backfill-state.json
```
4. **Run self-checks for anti-degeneration rules:**
```bash
# No concept filenames derived from change type
! find .okf -name "*.md" | grep -E "merge-pull-request|(^|/)(feat|fix|chore|docs)[:-]"
# No non-kebab-case characters in filenames
! find .okf -name "*.md" | grep -E "[^a-z0-9/.-]"
# No identical consecutive log bullets
awk 'p==$0 && /^- / {exit 1} {p=$0}' .okf/log.md
# Every concept declares its lifecycle (absent status reads as `stable`, SPEC §5.4)
[ -z "$(grep -rL '^status:' --include='*.md' .okf | grep -v '/index.md$\|/log.md$')" ]
```
These are lexical guards; they do not catch a bundle that asserts a superseded state. That
check is semantic and belongs to the weaver, at fold time, when it has both the analysis and
the earlier concepts on disk (§4 rule 5).
5. **Run coverage check to guarantee every event is mapped:**
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_backfill_events.py" \
--check-coverage events.jsonl .okf
```
Exit code 0 means all live events (non-skipped) appear in the bundle's `sources` or log.
Exit code 1 with a list of unmapped ids means the weaver skipped some events; fix and re-run.
6. **Validate bundle schema:**
```bash
uv run "${CLAUDE_SKILL_DIR}/../validate/scripts/okf_validate.py" .okf --strict
```
Fix every error before finishing.
7. **Report** the resulting bundle:
- Concept count per directory
- Log entry samples (first and last)
- Coverage check result (all events mapped)
- Validation result (pass/fail, warnings)
- Superseded resolutions (the weaver's `superseded` count) and deprecated concepts:
`grep -rl '^status: deprecated' --include='*.md' .okf | wc -l`
- Agents spawned per phase (analyzers, weaver invocations) and, when the host reports it,
tokens per phase
- Truncated analyses: `grep -l '^truncated: true' analyses/*.md | wc -l`, next to the
deterministic estimate of git events whose full diff exceeds the default cap:
`jq -r 'select(.skip==null and .source=="git") | [.files[]|.add+.del] | add' events.jsonl | awk '$1>300' | wc -l`
## 3. Skip rules
Events matching a rule below are marked `skip: <rule-id>` and skipped during
replay (cursor advances, no log entry):
| Rule | Condition | Rationale |
|------|-----------|-----------|
| `paths-only-generated` | Git commit touches ONLY lockfiles or generated code (vendor/, node_modules/, dist/, *.min.*, *.lock, etc.) | Noise: build byproducts, no knowledge content |
| `merge-no-files` | Git merge commit with no file changes (first-parent only) | Housekeeping |
| `session-command-noise` | Session user message is a slash command or <20 chars and has no useful outcome | Ephemeral UI/chatter |
Extend the first rule with `--skip-globs "extra/**"` for repo-specific patterns.
## 4. Anti-degeneration rules
The analyzer and weaver enforce these rules to prevent the bundle from devolving into
a mechanical listing of commits or a taxonomy-by-accident:
1. **Concept names describe domain entities, not changes.** Forbidden:
- Filenames derived from git subject: `merge-pull-request-#2-....md`, `feat:-add-docs-skill.md`, `fix:-handle-edge-case.md`
- Bare action words: `update.md`, `fix.md`, `add-feature.md`
- Non-kebab-case: spaces, underscores, capitals, special characters (allowed: `[a-z0-9-]` only)
Example: a commit with subject "feat: add presales pipeline" touches a domain concept.
The *concept* is named `presales-pipeline.md` (the entity), not `feat:-add-presales-pipeline.md`
(the change). The change history lives in frontmatter `sources` and the body's `## History`;
`log.md` holds only lifecycle events.
2. **Prefer update over create.** Every event is analyzed for which concepts it touches; if
a concept already exists and the analysis fits, update its `sources` and body. Only create
a new concept if the analysis reveals a distinct domain entity not yet captured.
3. **Log bullets are lifecycle events, and explain intent.** Only a concept created,
deprecated or superseded gets a bullet; a routine update does not. A bullet answers "why?"
from the commit body or session outcome, never just re-reads the subject:
- Bad: `- Added presales-pipeline.md feature`
- Good: `- **Creation**: [Presales pipeline](...), formalized to clarify handoff points`
4. **No identical consecutive bullets.** A run of same-concept bullets means updates leaked
into the log:
```
- Feature X update
- Feature X update
- Feature X update
```
Instead: no bullet at all; the updates live in the concept's body and `sources`.
5. **A reversal is not growth.** A replay walks the history forward, so a later event routinely
overturns an earlier one (a phase dropped, a limit changed, a target downgraded). "Prefer
update over create" is a merge rule and says nothing about this: applied alone it leaves the
bundle asserting both states in the present tense, in one concept or in two. The weaver greps
the whole bundle before writing and, on contradiction, replaces rather than appends: the body
states what holds today, the previous position drops to a dated line under `## History`, and
an outdated concept that a newer one supersedes gets `status: deprecated` plus a link to its
successor. A deprecated concept keeps its `sources`, so coverage stays green. None of the
guards in Finalize can catch this — they are lexical, and a superseded claim is well-formed.
6. **Reconstructed concepts are `draft`, never `stable`.** SPEC §5.4 reads an absent `status` as
`stable` ("ready for consumption"); a bundle nobody has reread is `draft` ("not yet reviewed;
possibly incomplete"). The weaver writes `status:` on every concept.
## 5. Implementation notes
- **Determinism**: extraction is byte-identical; replay is not (time, LLM variance).
Both are auditable: events.jsonl is deterministic, map analyses are stored and resumable,
reduce loop is human-readable and cursor-backed.
- **Privacy**: events.jsonl goes to scratchpad, never committed. Session turn text is
truncated (head+tail) before extraction.
- **Cost note**: deep replay reads one capped diff per git commit (§6) to extract
rationale. For histories >500 events, consider splitting into sub-ranges and
replaying sequentially, or use `--skip-globs` to exclude low-signal paths (e.g.,
vendored dependencies, generated code). Map phase is parallel in waves of 4 to 10: the
gate benchmark lost 260 of 299 runs to a high-concurrency mass failure and finished at
concurrency 4 (`benchmark/gate/RESULTS.md`). Reduce is sequential but much cheaper
(concepts already analyzed).
- **Future sources** (not implemented): GitHub PR/issue text, release notes, CI/deploy logs
— all optional post-MVP. **Lore protocol** (arXiv 2603.15566): on repos that adopt git
trailers (structured decision metadata), the extracted event's `body` already contains
trailers in a machine-readable format. The analyzer and weaver should use trailers as
the primary source of "why" (overriding generic inference from diff content).
- **Trust metadata**: `generated.by` is the backfill agent (`okf-backfill/0.9.5`), not
claimed as human-reviewed (`human:...`); concepts are correctly `unverified` (SPEC §5.3) and
`status: draft` (§5.4) — the two axes are independent: trust is who says it, lifecycle is
whether it has been reread. Writing `status` is not optional here: absent, §5.4 reads it as
`stable`, so a bundle nobody has reread would declare itself ready for consumption.
Map-phase analyses are working artifacts (stored for auditability during reduce); the
weaver's output is the canonical bundle.
## 6. Capped diff emitter
Analyzers never read a raw `git show`: the harness cuts long tool output blindly (mid-hunk,
character-based, host-dependent) and a cheap worker may not notice the cut. The extractor reads
the whole diff instead and emits a deterministic sample:
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_backfill_events.py" <repo-dir> --show <sha> \
[--only <path>] [--max-diff-lines 300] [--per-file-lines 120] [--max-line-chars 400] \
[--skip-globs "vendor/**"]
```
- Diff against the first parent, so merge commits agree with the extracted numstat.
- The stat is always complete: entities survive any cap.
- Patches are capped per file (breadth over depth) and cut at hunk or file boundaries, with a
bracketed marker at every cut; lines longer than `--max-line-chars` are shortened with a
marker (a generated one-line file is "1 line" but can be hundreds of KB).
- Patches of generated files (lockfiles, `vendor/**`, `--skip-globs`) are omitted even inside
mixed commits; their stat line stays.
- The last line is fixed-form: `[diff: shown=X total=Y files_shown=A files_total=B truncated=true|false]`.
The analyzer copies `truncated` into its frontmatter; finalize reports the count.
- `--only <path>` is the one permitted follow-up when a truncated diff hides the rationale:
same cap logic, one file.
Defaults keep one call under the harness limits with margin; tune per repo with the flags.
The analyzer's tier is configuration, not infrastructure: `agents/event-analyzer.md` carries
`model` and `effort` in its frontmatter. Fork the file to retier the map phase; the skill
resolves the agent by name and nothing else changes. The default is `haiku` since the
map-tier benchmark (`benchmark/map-tier/RESULTS.md`): parity with `sonnet` on commits at
under half the map cost, on the condition that the two explicit instructions in the agent
file stay (which summary line the flag copies; claims report intent as intent).
File diff suppressed because it is too large. Load diff
+159
View File
@@ -0,0 +1,159 @@
---
name: okf
description: >-
Author, maintain, and consume Open Knowledge Format (OKF) knowledge bundles —
portable markdown + YAML frontmatter that both humans and agents read. Use when
capturing project knowledge (services, APIs, schemas, metrics, runbooks,
decisions) into an OKF bundle, when updating one after code or docs change, or
when a repository contains an `.okf/` (or other OKF) bundle that should inform
the task. Triggers on: "document this in OKF", "update the knowledge bundle",
"capture this as a concept", or any work in a repo that has an OKF bundle.
user-invocable: true
argument-hint: "[produce|maintain|consume] [path]"
allowed-tools: Read Write Edit Grep Glob Bash
---
# Open Knowledge Format (OKF) skill
OKF represents knowledge as a directory of markdown files with YAML frontmatter.
It is minimal by design: no schema registry, no runtime, no SDK. Your job is to
produce, maintain, and consume OKF bundles **conformant with the spec**, not your
memory of it.
**Always read the canonical spec before non-trivial work:**
[reference/SPEC.md](reference/SPEC.md). It is the verbatim OKF v0.2 specification
and the source of truth for every rule below.
## The one hard rule
A bundle is conformant (§11) iff: every non-reserved `.md` file has a parseable
YAML frontmatter block, and every such block has a **non-empty `type`** field.
Everything else is soft guidance. Consumers MUST tolerate missing optional
fields, unknown types, and broken links — never reject a bundle over them.
## Conventions to apply
- **One concept = one file.** The file path (minus `.md`) is the concept ID.
- **Frontmatter:** `type` is required. Add `title`, `description`, `tags` when
they aid consumption; add `resource` (a canonical URI) only for concepts bound
to a real asset — omit it for abstract concepts.
- **Body:** prefer structural markdown (headings, tables, lists, fenced code).
Conventional headings: `# Schema`, `# Examples`, `# Computation`.
- **Cross-links:** standard markdown links; prefer absolute bundle-relative
form (`/services/auth-api.md`). A link asserts a relationship; its *kind* lives
in the surrounding prose, not the link.
- **Reserved files:** `index.md` (directory listing, no frontmatter, except the
bundle-root index, which may carry `okf_version` and this plugin's
`upkeep: enforced` opt-in flag) and `log.md` (ISO-dated history, newest
first). Never use these names for concepts.
- **What goes in `log.md`:** lifecycle events only, written in the bundle-root
`log.md`: a bundle **created**, a concept **deprecated** or retired, one
concept **superseding** another, concepts **regenerated** after their source
changed, a **verification** pass. Routine edits to a concept do not get an
entry: its `generated` and git already record them. A log is append-only, so
never rewrite or drop an existing entry.
## The v0.2 families (all optional, all worth filling)
- **Trust (§5.2):** `generated: { by, at }` — who produced the current content
and when. `verified: [{ by, at }]` — who confirmed it since (a bare mapping is
one entry). Write `by` in the **actor convention** (§7): `<producer>/<version>`
for an agent, `human:<id>` for a person, `process:<id>` for an automated job.
Use `human:` whenever a person authored or signed off — consumers key trust
tiers off that prefix.
- **Lifecycle (§5.4–5.5):** `status: draft|stable|deprecated` (absent means
stable) and `stale_after`, an absolute ISO 8601 datetime (a bare date is
tolerated), not a TTL.
- **Provenance (§5.1):** `sources: [{ id, resource, title, author,
usage_count, last_modified }]` plus a `usage_window: { from, to }` sibling of
`sources` framing every `usage_count` (an entry may carry its own to override
it); a `usage_count` without a window warns. `resource` is required per entry
and may be a URL, a bundle path, or a scope descriptor. Attribute a specific
claim with a markdown footnote whose label is the source's `id`:
`…sharded daily.[^ga4-schema]` plus a `[^ga4-schema]: …` definition. The label
is the join key, it must match a `sources[].id`.
- **Attestation (§10):** a sanctioned computation is its own concept,
`type: Attested Computation`, carrying `runtime` (required), `parameters`,
`executor`, `attester`, and the computation itself under `# Computation` (or a
`computation:` path). Concepts that need the value link to it. Never inline a
number's SQL into the concept that narrates it.
**Reading a v0.1 bundle?** Two constructs were superseded (§13.1): `timestamp`
is now `generated.at`, and a body `# Citations` list is now `sources`. Read both,
write v0.2 — and when you touch a legacy concept in **maintain** mode, migrate
its frontmatter as part of the edit. The validator warns on both.
Templates to copy: [concept](templates/concept.md), [index](templates/index.md),
[log](templates/log.md).
## Default bundle location
Use `.okf/` at the repository root unless the project already uses another
location. Commit it alongside the code it describes — knowledge as code.
## Modes
### produce — create or extend a bundle
**Starting a brand-new bundle?** Use the init fast-path instead of hand-writing
the first files — it scaffolds a conformant `index.md`, `log.md`, and a
`getting-started.md` concept with full recommended frontmatter in one shot:
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_init.py" <target-dir> [--title "..."]
```
It refuses to touch a directory that already has `.md` files unless `--force`
is given. Then extend it:
1. Read [reference/SPEC.md](reference/SPEC.md).
2. Pick the source(s): **code** (derive concepts from source, READMEs,
docstrings, config), **docs/wiki** (distill pages into concepts, record the
originals in `sources`), **manual** (decisions, playbooks, metrics).
3. Choose a directory layout by domain (e.g. `services/`, `datasets/`,
`decisions/`). One concept per file.
4. Write each concept from [templates/concept.md](templates/concept.md): set a
descriptive `type`, fill recommended fields, record `generated` and the
`sources` you actually read, cross-link related concepts.
5. Add/refresh `index.md` per directory (and `okf_version: "0.2"` in the root
index). A new bundle gets a dated **Creation** entry in `log.md`. Extending
an existing one does not.
6. Validate (see below). Fix every error before finishing.
### maintain — keep a bundle in sync with reality
1. Identify which concepts the change affects (search by `resource`, path, or
topic). This bookkeeping is exactly what agents are good at — touch every
affected file in one pass.
2. Update the body and `generated.at` (with your own actor in `generated.by`);
fix or add cross-links; create new concepts for new assets; mark removed
assets `status: deprecated` and note the deprecation in `log.md` rather than
silently deleting context. Facing a whole v0.1 bundle rather than a stray
field? Do not hand-edit it — run the validator's `--migrate` once.
3. Update the relevant `index.md` files. Append a dated `log.md` entry only if
the change is a lifecycle event (see *What goes in `log.md`*).
4. Validate.
### consume — use a bundle as context
1. Read the bundle-root `index.md` first for progressive disclosure, then follow
links only into the concepts relevant to the task.
2. Weigh what you read: `status: draft`/`deprecated`, a `stale_after` already
past, or no `verified` entry all mean "check before relying on this". Treat
broken links as not-yet-written knowledge, not errors.
3. Need a number an `Attested Computation` covers? Run *its* computation with
values bound to the declared `parameters` — never write your own query.
4. If you learn something durable while working, switch to **maintain** and
write it back.
## Validation (do this before declaring done)
Never eyeball conformance — run the deterministic checker. Invoke the companion
**`validate`** skill (`/okf:validate <bundle-dir> --strict`), which ships the
checker. If that skill is not installed, run it directly:
```bash
uv run "${CLAUDE_SKILL_DIR}/../validate/scripts/okf_validate.py" <bundle-dir> --strict
```
Resolve every `ERROR` (hard §11 failures). Under `--strict` warnings also fail
the run (exit 1), so clear them too; drop `--strict` (or use `--max-warnings N`)
if you only want §11 conformance gated.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6"]
# ///
"""Scaffold a conformant starter Open Knowledge Format (OKF) v0.2 bundle.
Creates a root `index.md` (frontmatter carries only `okf_version`), a `log.md`
with a creation entry under today's ISO date heading, and one starter concept
(`getting-started.md`) with the full set of recommended frontmatter fields.
The scaffold is meant to be exemplary: `okf_validate.py --strict` should pass
it with zero warnings.
Run: uv run scripts/okf_init.py <target-dir> [--title "..."] [--force]
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
OKF_VERSION = "0.2"
STARTER_CONCEPT = "getting-started.md"
# Actor convention (§7): an automated process, not an agent with a version.
ACTOR = "process:okf_init"
def humanize(name: str) -> str:
words = name.replace("-", " ").replace("_", " ").strip()
return words.title() if words else "Untitled"
def frontmatter(meta: dict) -> str:
return "---\n" + yaml.safe_dump(meta, sort_keys=False, allow_unicode=True) + "---\n"
def build_index(title: str) -> str:
meta = frontmatter({"okf_version": OKF_VERSION})
return (
f"{meta}\n"
f"# {title}\n\n"
f"* [Getting started]({STARTER_CONCEPT}) - starting point for this bundle.\n"
)
def build_log(today: str, title: str) -> str:
return (
"# Update Log\n\n"
f"## {today}\n"
f"* **Creation**: Scaffolded the {title} bundle with `okf_init.py` — "
f"see [getting started]({STARTER_CONCEPT}).\n"
)
def build_concept(title: str, now_iso: str) -> str:
meta = frontmatter({
"type": "Reference",
"title": f"Getting started — {title}",
"description": f"Starting point for the {title} OKF bundle.",
"tags": ["getting-started"],
"status": "stable",
"generated": {"by": ACTOR, "at": now_iso},
})
return (
f"{meta}\n"
"# Overview\n\n"
"This is the first concept in a freshly scaffolded OKF bundle. Replace "
"it with real knowledge — one concept per file, cross-linked with "
"standard markdown links — and keep `index.md` updated as you go. "
"`log.md` records lifecycle events only: a concept created, "
"deprecated, superseded, regenerated or verified.\n"
)
def main() -> int:
ap = argparse.ArgumentParser(description=f"Scaffold a starter OKF v{OKF_VERSION} bundle.")
ap.add_argument("target", type=Path, help="directory to create the bundle in")
ap.add_argument("--title", default=None, help="bundle title (default: humanized target dir name)")
ap.add_argument("--force", action="store_true", help="scaffold even if the target already has .md files")
args = ap.parse_args()
if args.target.exists() and not args.target.is_dir():
print(f"error: {args.target} exists and is not a directory", file=sys.stderr)
return 2
existing_md = list(args.target.rglob("*.md")) if args.target.is_dir() else []
if existing_md and not args.force:
print(f"refusing: {args.target} already contains .md files (pass --force to scaffold anyway)", file=sys.stderr)
for p in sorted(existing_md)[:5]:
print(f" {p.relative_to(args.target)}", file=sys.stderr)
return 1
title = args.title or humanize(args.target.resolve().name)
today = datetime.now(timezone.utc).date().isoformat()
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
args.target.mkdir(parents=True, exist_ok=True)
(args.target / "index.md").write_text(build_index(title), encoding="utf-8")
(args.target / "log.md").write_text(build_log(today, title), encoding="utf-8")
(args.target / STARTER_CONCEPT).write_text(build_concept(title, now_iso), encoding="utf-8")
validator = Path(__file__).resolve().parents[2] / "validate" / "scripts" / "okf_validate.py"
print(f"created OKF bundle scaffold at {args.target}")
print(f"hint: validate it — uv run {validator} {args.target} --strict (or the validate skill)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -0,0 +1,38 @@
---
type: <Concept type, e.g. Service, BigQuery Table, Metric, Playbook, Decision>
title: <Human-readable display name>
description: <Single sentence summarizing the concept.>
resource: <Canonical URI of the underlying asset — omit for abstract concepts>
tags: [<tag>, <tag>]
status: stable # draft | stable | deprecated; absent means stable
generated: { by: <actor>, at: <ISO 8601, e.g. 2026-06-14T10:00:00Z> }
verified: { by: human:<id>, at: <ISO 8601> } # omit until someone confirms it
stale_after: <YYYY-MM-DDTHH:MM:SSZ> # omit when the content does not expire
sources: # what this was derived from; omit if nothing
- id: <short-key>
resource: <URL, bundle path, or scope descriptor>
title: <Human-readable label>
author: <actor> # optional credibility signal
last_modified: <YYYY-MM-DDTHH:MM:SSZ> # when the source itself last changed
---
<!--
Actors (§7): `<producer>/<version>` for an agent, `human:<id>` for a person,
`process:<id>` for an automated job. Use `human:` whenever a person authored
or signed off — consumers key trust tiers off that prefix.
-->
# Overview
<What this concept is and why it matters. Attribute a sourced claim with a
footnote whose label is a `sources[].id`.[^short-key]>
# Schema
<Use for assets with fields/columns; otherwise replace with relevant sections.>
| Field | Type | Description |
|-------|------|-------------|
| | | |
[^short-key]: <Human-readable label of the source>
+8
View File
@@ -0,0 +1,8 @@
# <Directory / Group Heading>
* [<Title>](<relative-url>) - <short description from the concept's frontmatter>
* [<Title>](<relative-url>) - <short description>
# <Another Group>
* [<Subdirectory>](<subdir>/) - <short description of the subdirectory>
+8
View File
@@ -0,0 +1,8 @@
# Update Log
## <YYYY-MM-DD>
* **Creation**: <what was created> — [<concept>](<bundle-relative-path>).
* **Deprecation**: <what was retired, and what replaces it> — [<concept>](<bundle-relative-path>).
* **Supersession**: [<new concept>](<bundle-relative-path>) supersedes [<old concept>](<bundle-relative-path>). Affirmed by <actor>.
* **Regeneration**: <which concepts, after what source change> — [<concept>](<bundle-relative-path>).
* **Verification**: <what was verified, by whom> — [<concept>](<bundle-relative-path>).
+85
View File
@@ -0,0 +1,85 @@
---
name: validate
description: >-
Check that an Open Knowledge Format (OKF) bundle is conformant with the v0.2
spec (§11). Use when asked to validate, lint, or check an OKF bundle, or before
committing changes to one. Runs a deterministic Python checker — not an
eyeball pass. Also migrates a v0.1 bundle to v0.2 in place with `--migrate`.
user-invocable: true
argument-hint: "[bundle-dir] [--strict | --max-warnings N] [--migrate]"
allowed-tools: Bash
---
# Validate an OKF bundle
Run the deterministic conformance checker against the target bundle. Default to
the project's `.okf/` directory when no path is given.
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_validate.py" $ARGUMENTS
```
If `uv` is unavailable, fall back to:
```bash
python3 -m pip install --quiet pyyaml && \
python3 "${CLAUDE_SKILL_DIR}/scripts/okf_validate.py" $ARGUMENTS
```
`${CLAUDE_SKILL_DIR}` resolves whether this skill runs as part of the `okf`
plugin or is installed standalone (e.g. via `npx skills add`), so the checker is
always found alongside the skill.
Interpret the result:
- **ERROR** → a hard §11 conformance failure (no parseable frontmatter, a
missing/empty `type`, or a file the checker cannot read as UTF-8). The bundle
is non-conformant. Fix every one.
Only `index.md` and `log.md` are reserved: §11.1 makes *every* other `.md` in
the tree a concept, convention files included. An `AGENTS.md` or `README.md`
dropped next to a bundle is therefore an ERROR until it carries frontmatter with
a `type` (`Document` reads well), which is the fix; a skip-list would put the
checker out of conformance. Move the file above the bundle root if it is not
knowledge.
- **warn** → soft guidance (missing recommended field, non-ISO log date, broken
cross-link, a malformed v0.2 family, a footnote naming no source, an actor
that misses the §7 shapes, an Attested Computation missing `runtime`, a
computation path that resolves nowhere, frontmatter in a reserved file (§8,
§9), a root `index.md` carrying extra keys or declaring a different
`okf_version` (§12)). Never blocks; broken links in particular are explicitly
tolerated by the spec (§6.1). Fix when cheap.
One warning is worth more than the others: a §7 near-miss such as `Human:dana`
or `human/dana`. §5.3 keys trust tiers off the exact lowercase `human:` prefix,
so the concept silently reads as machine-confirmed when a person did review it.
Fix that one on sight.
v0.1 bundles validate too: a legacy `timestamp` or `# Citations` section is
reported as a warning naming its v0.2 replacement (`generated.at`, `sources`),
never as an error (§13.1). Under `--strict` those warnings do fail the run —
that is the migration nudge, and `--migrate` is the door.
## Migrating a v0.1 bundle
`--migrate` **rewrites the bundle in place** before validating — the one mode of
this skill that is not read-only, so say what it will touch before running it on
a bundle the user has not asked to migrate. It is textual (comments, key order
and quoting survive) and idempotent.
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_validate.py" .okf --migrate --strict
```
It hoists `timestamp` to `generated: { by: process:okf-migrate, at }`, lifts a
`# Citations` list into `sources`, and bumps `okf_version`. Two limits worth
repeating to the user: `generated.by` cannot be recovered for pre-v0.2 content
(hence the `process:` actor — the concept stays correctly `unverified` under
§5.3), and per-claim `[^id]` attribution was never encoded in v0.1, so only the
source list moves up.
## Exit codes
Non-zero if any error is present, or if warnings exceed the gate:
`--strict` allows none, `--max-warnings N` allows N, the default allows any.
Add `--json` for machine-readable output (useful in CI).
+573
View File
@@ -0,0 +1,573 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6"]
# ///
"""Deterministic conformance checker for Open Knowledge Format (OKF) v0.2.
Implements the §11 conformance rules verbatim:
1. Every non-reserved `.md` file contains a parseable YAML frontmatter block.
2. Every frontmatter block contains a non-empty `type` field.
3. Reserved filenames (`index.md`, `log.md`) follow §8 / §9 when present.
Rules 1 and 2 are hard errors (a bundle that fails them is non-conformant).
Everything else the spec marks as soft guidance: reported as warnings, never
fatal unless `--strict` is given. In particular broken cross-links are NOT
errors — the spec requires consumers to tolerate them (§6.1).
v0.1 bundles still validate: the two superseded constructs (`timestamp`, a body
`# Citations` list) are read and reported as warnings pointing at their v0.2
replacements (`generated.at`, `sources`), never as errors (§13.1). `--migrate`
performs that rewrite in place, textually, so `--strict` has a door and not just
a wall.
Run: uv run scripts/okf_validate.py <bundle-dir>
[--strict | --max-warnings N] [--migrate] [--json]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
import yaml
OKF_VERSION = "0.2"
RESERVED = {"index.md", "log.md"}
# `resource` is intentionally excluded: the spec (§4.1) states it is absent for
# concepts describing abstract ideas, so flagging it produces false noise.
RECOMMENDED = ("title", "description", "tags")
STATUS_VALUES = {"draft", "stable", "deprecated"}
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# RFC 3339, as the spec writes `generated.at` / `verified[].at` throughout, and
# (since 0.9.6) `stale_after`, `sources[].last_modified` and `usage_window`. A
# date-only value is tolerated: it is common in the wild and loses only precision.
# PyYAML resolves an unquoted timestamp to a datetime whose str() separates with a
# space, so both spellings have to pass.
RFC3339 = re.compile(
r"^\d{4}-\d{2}-\d{2}"
r"(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$")
# §7 gives three actor shapes, and §5.1 reuses them for `sources[].author`. The
# spec's own example (`author: team:ga4-docs`) shows the `<prefix>:<id>` family is
# open, so this is deliberately not a whitelist — it catches the one failure that
# is silent and costly: a near-miss of `human:`, which §5.3 keys trust tiers off.
# `human/dana` or `Human:dana` reads as an agent and downgrades the tier with no
# other symptom.
ACTOR_SHAPE = re.compile(r"^(?:[^\s:/]+:\S+|\S+/\S+)$")
# Anything that opens with human/process in any case — `Human:dana` is the worst
# of these, since it satisfies the generic `<prefix>:<id>` shape and so looks
# entirely well-formed while §5.3 reads it as an agent. The lookahead keeps
# `humanoid_agent/v1` out: only a non-identifier boundary makes it a near-miss.
ACTOR_HUMANISH = re.compile(r"^(?:human|process)(?![A-Za-z0-9_])", re.I)
FENCE = re.compile(r"^(```|~~~)")
# markdown link target capture: [text](target) — ignores images ![...]
LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
CITATIONS = re.compile(r"^#{1,6}[ \t]+Citations[ \t]*$", re.M)
# a footnote *reference* in the body; the definition line `[^id]: …` matches too,
# which is harmless — both carry the same label, the join key into `sources`.
FOOTNOTE = re.compile(r"\[\^([^\]\s]+)\]")
# --migrate only. `timestamp` is a top-level key (§4.1), so anchor at column 0 —
# an indented one belongs to something else and must not be hoisted.
TIMESTAMP = re.compile(r"^timestamp:[ \t]*(.+?)[ \t]*$", re.M)
HEADING = re.compile(r"^#{1,6}[ \t]", re.M)
# same shape as LINK above, plus the link text — a citation written with a title
# attribute, `[Src](url "Title")`, must not be dropped: the section is deleted
# wholesale once anything parses, so a miss here is silent data loss.
MD_LINK = re.compile(r"(?<!\!)\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
OKF_VERSION_LINE = re.compile(r"^(okf_version:[ \t]*)[\"']?0\.1[\"']?[ \t]*$", re.M)
# True, and it keeps the concept correctly `unverified` under §5.3: who wrote the
# content before `generated` existed is not recoverable, and inventing a `human:`
# actor would fake a review that never happened.
MIGRATE_ACTOR = "process:okf-migrate"
@dataclass
class Report:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
concepts: int = 0
indexes: int = 0
logs: int = 0
def err(self, rel: str, msg: str) -> None:
self.errors.append(f"{rel}: {msg}")
def warn(self, rel: str, msg: str) -> None:
self.warnings.append(f"{rel}: {msg}")
def split_frontmatter(text: str) -> tuple[str | None, str]:
"""Return (raw_yaml, body). raw_yaml is None when no frontmatter block."""
if not text.startswith("---"):
return None, text
# first line must be exactly '---' (allow trailing whitespace / BOM already stripped)
lines = text.splitlines(keepends=True)
if lines[0].strip() != "---":
return None, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return "".join(lines[1:i]), "".join(lines[i + 1 :])
return None, text # unterminated block → treated as absent
def _read_text(path: Path, rel: str, report: Report) -> str | None:
"""Read a concept/index/log file, reporting (not raising) on a bad file.
A binary or non-UTF-8 `.md` file must fail as a per-file error, not crash
the whole validation run.
"""
try:
return path.read_text(encoding="utf-8").lstrip("")
except (UnicodeDecodeError, OSError) as exc:
report.err(rel, f"cannot read file: {exc}")
return None
def check_computation(meta: dict, path: Path, bundle: Path, rel: str, report: Report) -> None:
"""§10.2 — the Attested Computation contract, including its path-valued fields.
`computation`, `executor.resource` and `attester.resource` name files (§6.2).
They are exactly the pointer that rots: the concept keeps validating while the
script it names moves or disappears. A target outside the bundle is legitimate
and unverifiable from here, so only bundle-relative paths are resolved.
"""
if not str(meta.get("runtime", "")).strip():
report.warn(rel, "§10.2 an Attested Computation concept requires `runtime`")
fields = [("computation", meta.get("computation"))]
for key in ("executor", "attester"):
block = meta.get(key)
if isinstance(block, dict):
fields.append((f"{key}.resource", block.get("resource")))
for where, target in fields:
if target is None:
continue
t = str(target).strip()
if not t or re.match(r"^[a-z][a-z0-9+.-]*://", t) or t.startswith("mailto:"):
continue
candidate = (bundle / t.lstrip("/")) if t.startswith("/") else (path.parent / t)
try:
inside = candidate.resolve().is_relative_to(bundle.resolve())
except OSError:
inside = False
if inside and not candidate.exists():
report.warn(rel, f"§6.2 `{where}` points at `{t}`, which does not exist")
def check_concept(path: Path, rel: str, bundle: Path, report: Report) -> None:
report.concepts += 1
text = _read_text(path, rel, report)
if text is None:
return
raw, body = split_frontmatter(text)
if raw is None:
report.err(rel, "§11.1 no parseable YAML frontmatter block "
"(every non-reserved `.md` in a bundle is a concept: add "
"frontmatter with a `type` field, or move the file outside "
"the bundle)")
return
try:
meta = yaml.safe_load(raw)
except yaml.YAMLError as exc:
report.err(rel, f"§11.1 frontmatter is not valid YAML: {exc}".replace("\n", " "))
return
if not isinstance(meta, dict):
report.err(rel, "§11.1 frontmatter must be a YAML mapping")
return
type_val = meta.get("type")
if not (isinstance(type_val, str) and type_val.strip()):
report.err(rel, "§11.2 missing or empty required `type` field")
for key in RECOMMENDED:
if key not in meta:
report.warn(rel, f"recommended field `{key}` is absent (§4.1)")
check_trust(meta, rel, report)
check_lifecycle(meta, rel, report)
check_sources(meta, body, rel, report)
if isinstance(type_val, str) and type_val.strip() == "Attested Computation":
check_computation(meta, path, bundle, rel, report)
def check_actor(value, where: str, rel: str, report: Report) -> None:
"""§7 — the actor convention shared by `generated.by`, `verified[].by` and
`sources[].author`."""
actor = str(value).strip()
if not actor:
return
if ACTOR_HUMANISH.match(actor) and not actor.startswith(("human:", "process:")):
report.warn(rel, f"§7 `{where}` `{actor}` is a near-miss of "
f"`human:`/`process:` — §5.3 keys trust tiers off that exact "
f"lowercase prefix, so this silently reads as an agent")
elif not ACTOR_SHAPE.match(actor):
report.warn(rel, f"§7 `{where}` `{actor}` matches no actor shape "
f"(`human:<id>`, `process:<id>`, or `<producer>/<version>`)")
def check_instant(value, where: str, rel: str, report: Report) -> None:
"""§5.2 — `at` values are RFC 3339; a date-only value is tolerated."""
if value is not None and not RFC3339.match(str(value).strip()):
report.warn(rel, f"§5.2 `{where}` `{value}` is not an RFC 3339 timestamp")
def check_trust(meta: dict, rel: str, report: Report) -> None:
"""§5.2 — `generated` / `verified`, with the v0.1 `timestamp` fallback."""
gen = meta.get("generated")
if gen is None:
if "timestamp" in meta:
report.warn(rel, "legacy v0.1 `timestamp`: v0.2 records the last content "
"change as `generated: {by, at}` (§13.1)")
else:
report.warn(rel, "recommended field `generated` is absent (§5.2)")
elif not isinstance(gen, dict):
report.warn(rel, "§5.2 `generated` must be a mapping with `by` and `at`")
elif not str(gen.get("by", "")).strip():
report.warn(rel, "§5.2 `generated.by` is required within `generated`")
else:
check_actor(gen["by"], "generated.by", rel, report)
check_instant(gen.get("at"), "generated.at", rel, report)
ver = meta.get("verified")
if ver is None:
return
# A bare mapping is one verification event — consumers MUST treat it as a
# one-element list (§5.2), so normalize before checking the entries.
entries = [ver] if isinstance(ver, dict) else ver
if not isinstance(entries, list):
report.warn(rel, "§5.2 `verified` must be a `{by, at}` mapping or a list of them")
return
for i, entry in enumerate(entries):
if not (isinstance(entry, dict) and str(entry.get("by", "")).strip()):
report.warn(rel, "§5.2 every `verified` entry needs a `by` actor")
continue
check_actor(entry["by"], f"verified[{i}].by", rel, report)
check_instant(entry.get("at"), f"verified[{i}].at", rel, report)
def check_lifecycle(meta: dict, rel: str, report: Report) -> None:
"""§5.4 / §5.5 — `status` and `stale_after`. A concept is stale when
`now >= stale_after`."""
status = meta.get("status")
if status is not None and status not in STATUS_VALUES:
report.warn(rel, f"§5.4 unknown `status` `{status}` (expected "
f"{'|'.join(sorted(STATUS_VALUES))})")
stale = meta.get("stale_after")
if stale is not None and not RFC3339.match(str(stale)):
report.warn(rel, f"§5.5 `stale_after` `{stale}` is not an ISO 8601 datetime "
f"(a bare date is tolerated)")
def check_window(window, where: str, rel: str, report: Report) -> None:
"""§5.1 — a `usage_window` is a `{from, to}` pair of ISO 8601 datetimes."""
if window is None:
return
if not isinstance(window, dict):
report.warn(rel, f"§5.1 `{where}` `usage_window` must be a `{{from, to}}` mapping")
return
for bound in ("from", "to"):
value = window.get(bound)
if value is None:
report.warn(rel, f"§5.1 `{where}` `usage_window` is missing `{bound}`")
elif not RFC3339.match(str(value)):
report.warn(rel, f"§5.1 `{where}` `usage_window.{bound}` `{value}` "
f"is not an ISO 8601 datetime (a bare date is tolerated)")
def check_sources(meta: dict, body: str, rel: str, report: Report) -> None:
"""§5.1 — the `sources` provenance family and its footnote join keys."""
if CITATIONS.search(body):
report.warn(rel, "legacy v0.1 `# Citations` body list: v0.2 records provenance "
"in the `sources` frontmatter (§13.1)")
srcs = meta.get("sources")
if srcs is None:
return
if not isinstance(srcs, list):
report.warn(rel, "§5.1 `sources` must be a list of entries")
return
ids = set()
for i, src in enumerate(srcs):
if not isinstance(src, dict):
report.warn(rel, f"§5.1 `sources[{i}]` must be a mapping")
continue
if not str(src.get("resource", "")).strip():
report.warn(rel, f"§5.1 `sources[{i}]` is missing the required `resource`")
if "id" in src:
ids.add(str(src["id"]))
if src.get("author") is not None:
check_actor(src["author"], f"sources[{i}].author", rel, report)
# §5.1 — `usage_count` is framed by a `usage_window`, written once as a
# sibling of `sources` or overridden on the entry. Without one the count
# is a number with no units, and the spec asks consumers to read it as
# liveness and trend rather than a score.
window = src.get("usage_window", meta.get("usage_window"))
if src.get("usage_count") is not None and window is None:
report.warn(rel, f"§5.1 `sources[{i}].usage_count` has no `usage_window` "
f"framing it (a sibling of `sources`, or on the entry)")
check_window(window, f"sources[{i}]", rel, report)
last_mod = src.get("last_modified")
if last_mod is not None and not RFC3339.match(str(last_mod)):
report.warn(rel, f"§5.1 `sources[{i}].last_modified` `{last_mod}` is not an "
f"ISO 8601 datetime (a bare date is tolerated)")
# Attribution joins on the label, not on position (§5.1) — a footnote whose
# label names no source silently attributes a claim to nothing.
for label in sorted(set(FOOTNOTE.findall(body)) - ids):
report.warn(rel, f"§5.1 footnote `[^{label}]` matches no `sources[].id`")
def check_index(path: Path, rel: str, is_root: bool, report: Report) -> None:
report.indexes += 1
text = _read_text(path, rel, report)
if text is None:
return
raw, _ = split_frontmatter(text)
if raw is not None:
if not is_root:
report.warn(rel, "§8 index.md should contain no frontmatter")
else:
try:
meta = yaml.safe_load(raw) or {}
except yaml.YAMLError:
report.warn(rel, "§12 root index.md frontmatter is not valid YAML")
meta = {}
# `upkeep` rides along: the plugin's Stop hook reads its opt-in flag
# from here, so an enforced-mode bundle would otherwise fail its own
# `--strict` run. §11 forbids rejecting a bundle for unknown keys.
extra = set(meta) - {"okf_version", "upkeep"}
if extra:
report.warn(rel, f"§12 root index.md frontmatter may only carry `okf_version` (found {sorted(extra)})")
declared = meta.get("okf_version")
if declared is not None and str(declared) != OKF_VERSION:
report.warn(rel, f"§12 bundle declares `okf_version: \"{declared}\"`; "
f"checked against v{OKF_VERSION}")
def check_log(path: Path, rel: str, report: Report) -> None:
report.logs += 1
text = _read_text(path, rel, report)
if text is None:
return
raw, _ = split_frontmatter(text)
if raw is not None:
report.warn(rel, "§9 log.md should contain no frontmatter")
for line in text.splitlines():
if line.startswith("## "):
heading = line[3:].strip()
if not ISO_DATE.match(heading):
report.warn(rel, f"§9 date heading `{heading}` is not ISO 8601 YYYY-MM-DD")
def collect_link_targets(path: Path) -> list[str]:
# Unreadable files are already reported as a per-file error by
# check_concept/check_index/check_log; fail soft here to avoid a duplicate
# error and a crash on the second read pass.
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
targets: list[str] = []
in_fence = False
for line in text.splitlines():
if FENCE.match(line.strip()):
in_fence = not in_fence
continue
if in_fence:
continue
targets.extend(LINK.findall(line))
return targets
def check_links(bundle: Path, md_files: list[Path], report: Report) -> None:
"""Broken bundle-internal links are warnings only (§6.1)."""
existing = {p.relative_to(bundle).as_posix() for p in md_files}
for path in md_files:
rel = path.relative_to(bundle).as_posix()
for target in collect_link_targets(path):
t = target.split("#", 1)[0]
if not t or t.endswith("/"):
continue
if re.match(r"^[a-z][a-z0-9+.-]*://", t) or t.startswith("mailto:"):
continue
if not t.endswith(".md"):
continue
if t.startswith("/"):
resolved = t.lstrip("/")
else:
resolved = (path.parent / t).resolve().relative_to(bundle.resolve()).as_posix() \
if (path.parent / t).resolve().is_relative_to(bundle.resolve()) else t
if resolved not in existing:
report.warn(rel, f"cross-link target not found: `{target}` (tolerated under §6.1)")
def citations_to_sources(raw: str, body: str) -> tuple[str, str]:
"""Hoist a v0.1 `# Citations` list into the `sources` family (§5.1, §13.1)."""
heading = CITATIONS.search(body)
if heading is None:
return raw, body
rest = body[heading.end():]
nxt = HEADING.search(rest)
section, tail = (rest[:nxt.start()], rest[nxt.start():]) if nxt else (rest, "")
entries = MD_LINK.findall(section)
if not entries:
return raw, body # nothing extractable — leave it and keep warning
# json.dumps gives a correctly escaped YAML double-quoted scalar for free.
block = "sources:\n" + "".join(
f" - resource: {json.dumps(url)}\n title: {json.dumps(title)}\n"
for title, url in entries)
kept = body[:heading.start()].rstrip("\n")
return raw + block, kept + ("\n\n" + tail if tail else "\n")
def migrate_text(text: str, is_root_index: bool) -> str | None:
"""Rewrite one file's v0.1 constructs to v0.2. None when nothing changes.
Textual on purpose: dumping the frontmatter back through PyYAML would flatten
comments, key order and quoting across every file in the bundle. Each branch
is guarded on the v0.2 key already being present, so a half-migrated bundle
converges instead of growing a duplicate key that PyYAML then refuses to load.
"""
raw, body = split_frontmatter(text)
if raw is None:
return None
if is_root_index:
new_raw = OKF_VERSION_LINE.sub(rf'\g<1>"{OKF_VERSION}"', raw, count=1)
return None if new_raw == raw else f"---\n{new_raw}---\n{body}"
try:
meta = yaml.safe_load(raw)
except yaml.YAMLError:
return None
if not isinstance(meta, dict):
return None
new_raw, new_body = raw, body
if "generated" not in meta:
new_raw = TIMESTAMP.sub(
lambda m: f"generated:\n by: {MIGRATE_ACTOR}\n at: {m.group(1)}",
new_raw, count=1)
if "sources" not in meta:
new_raw, new_body = citations_to_sources(new_raw, new_body)
if (new_raw, new_body) == (raw, body):
return None
return f"---\n{new_raw}---\n{new_body}"
def migrate(bundle: Path) -> list[str]:
"""Migrate a bundle in place. Returns the relative paths actually rewritten."""
changed = []
for path in sorted(p for p in bundle.rglob("*.md") if p.is_file()):
rel = path.relative_to(bundle).as_posix()
if path.name == "log.md":
continue
try:
# Universal newlines on purpose: a CRLF bundle is rewritten with the
# platform ending, consistently. Passing newline="" to preserve them
# exactly would leave a stray \r inside every `$`-anchored capture
# below, so those patterns need hardening first if byte fidelity is
# ever required.
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
is_root_index = path.name == "index.md" and path.parent == bundle
if path.name == "index.md" and not is_root_index:
continue
new = migrate_text(text.lstrip(""), is_root_index)
if new is not None and new != text:
path.write_text(new, encoding="utf-8")
changed.append(rel)
return changed
def validate(bundle: Path) -> Report:
report = Report()
md_files = sorted(p for p in bundle.rglob("*.md") if p.is_file())
for path in md_files:
rel = path.relative_to(bundle).as_posix()
name = path.name
if name == "index.md":
check_index(path, rel, is_root=(path.parent == bundle), report=report)
elif name == "log.md":
check_log(path, rel, report)
else:
check_concept(path, rel, bundle, report)
check_links(bundle, md_files, report)
return report
def _force_utf8_stdio() -> None:
"""Ensure stdout/stderr can emit the ✓/✗/— glyphs this tool prints.
Windows consoles default to cp1252, which raises UnicodeEncodeError on those
characters and crashes an otherwise-successful run. Reconfiguring to UTF-8
(Python ≥3.7) fixes it; `errors="replace"` is a belt-and-suspenders fallback
for any stream that still cannot encode a glyph.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError):
pass
def main() -> int:
_force_utf8_stdio()
ap = argparse.ArgumentParser(description=f"Validate an OKF v{OKF_VERSION} bundle.")
ap.add_argument("bundle", type=Path, help="path to the bundle directory")
ap.add_argument("--strict", action="store_true", help="treat warnings as errors")
ap.add_argument("--max-warnings", type=int, default=None, metavar="N",
help="fail if warnings exceed N (--strict is the N=0 case)")
ap.add_argument("--migrate", action="store_true",
help="rewrite v0.1 constructs to v0.2 in place, then validate")
ap.add_argument("--json", action="store_true", help="emit a JSON report")
args = ap.parse_args()
if not args.bundle.is_dir():
print(f"error: {args.bundle} is not a directory", file=sys.stderr)
return 2
migrated = migrate(args.bundle) if args.migrate else []
r = validate(args.bundle)
conformant = not r.errors
max_warn = 0 if args.strict else args.max_warnings
failed = bool(r.errors) or (max_warn is not None and len(r.warnings) > max_warn)
if args.json:
print(json.dumps({
"bundle": str(args.bundle),
"conformant": conformant,
"passed": not failed,
"counts": {"concepts": r.concepts, "indexes": r.indexes, "logs": r.logs},
"errors": r.errors,
"warnings": r.warnings,
"migrated": migrated,
}, indent=2))
return 0 if not failed else 1
if args.migrate:
for rel in migrated:
print(f" \033[36m→ migrated\033[0m {rel}")
print(f" migrated {len(migrated)} file(s) to v{OKF_VERSION}"
+ (f", `generated.by` recorded as `{MIGRATE_ACTOR}`" if migrated else ""))
if migrated:
print(" note: per-claim attribution (the `[^id]` footnotes of §5.1) is not"
" recoverable from a v0.1 `# Citations` list — sources moved up, but"
" which claim cited which was never encoded.")
print(f"OKF v{OKF_VERSION} conformance — {args.bundle}")
print(f" concepts: {r.concepts} index.md: {r.indexes} log.md: {r.logs}")
for e in r.errors:
print(f" \033[31m✗ ERROR\033[0m {e}")
for w in r.warnings:
print(f" \033[33m! warn \033[0m {w}")
if conformant and not r.warnings:
print(" \033[32m✓ conformant — no issues\033[0m")
elif conformant:
print(f" \033[32m✓ conformant\033[0m ({len(r.warnings)} warning(s))")
else:
print(f" \033[31m✗ non-conformant\033[0m ({len(r.errors)} error(s))")
return 0 if not failed else 1
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
---
name: visualize
description: >-
Render an Open Knowledge Format (OKF) bundle as a single self-contained,
interactive HTML graph (viz.html) — concepts as nodes coloured by type and
sized by body length, markdown links and bundle-internal `sources` as edges,
a wiki-style detail panel with rendered markdown, v0.2 trust/lifecycle/provenance
metadata, and "Links to" / "Cited by" backlinks, layout switching, per-type
filter and search.
Use when asked to visualize, graph, preview, or explore an OKF bundle.
user-invocable: true
argument-hint: "<bundle-dir> [-o viz.html]"
allowed-tools: Bash
---
# Visualize an OKF bundle
Generate a self-contained HTML graph of the target bundle. The bundle directory
is required, so pass `.okf` explicitly when no path is given. No backend, no
install, no bundle content leaves the page. The viewer does need network
access: cytoscape, marked and dompurify load from cdn.jsdelivr.net.
```bash
uv run "${CLAUDE_SKILL_DIR}/scripts/okf_visualize.py" $ARGUMENTS
```
If `uv` is unavailable:
```bash
python3 -m pip install --quiet pyyaml && \
python3 "${CLAUDE_SKILL_DIR}/scripts/okf_visualize.py" $ARGUMENTS
```
The detail panel shows each concept's `type`, `description`, `tags`, `status`,
`generated`, `verified`, `stale_after`, `sources` (with their credibility
signals, a `usage_count` alongside the `usage_window` that frames it), and
rendered body; a `sources` entry pointing at another concept in the bundle also
becomes a graph edge. A v0.1 `timestamp` is read as `generated.at`, so legacy
bundles still render fully.
Two badges are **derived**, not read: the §5.3 trust tier
(*unverified* / *machine-confirmed* / *human-reviewed*, keyed off the `human:`
prefix in `verified[].by`) and staleness (`now >= stale_after`). They are
advisory signals — when reporting on a bundle, say which tier a concept is in
rather than treating any of them as a gate.
| Flag | Effect |
|------|--------|
| `-o <path>` | Output file (default `<bundle>/viz.html`). |
| `-t, --title` | Graph title (default `parent/bundle` dir name). |
| `-l, --link` | Source URL shown in the header. |
| `--layout` | `cose`, `concentric`, `breadthfirst`, `circle`, `grid`. |
| `--max-nodes N` | Exit 1 above N concepts (useful in CI). |
| `--og-image URL` | Absolute URL for the social preview image. |
Above 1,000 concepts the default switches to `concentric` (force freezes the
page; `--layout cose` overrides, and the in-page picker asks first). Above
5,000 it warns that the page loads slowly and reads as a hairball, and
suggests rendering a subdirectory.
Open it in any browser; `${CLAUDE_SKILL_DIR}` resolves whether this runs as part
of the `okf` plugin or as a standalone skills.sh skill. The page is
deep-linkable: `?select=<concept-id>` or `#<concept-id>` opens that concept,
`?layout=<name>` presets the layout.
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml>=6"]
# ///
"""Render an Open Knowledge Format (OKF) bundle as a single self-contained,
interactive HTML graph (`viz.html`). No backend, no install on the viewing side,
no data leaves the page — concepts become nodes (coloured by `type`, sized by
body length), markdown links and bundle-internal `sources` become edges, and
clicking a node opens a wiki-style panel with its rendered markdown, OKF v0.2
provenance/trust/lifecycle metadata, outgoing links, and "Cited by" backlinks.
Features: force/concentric/breadth-first/circle/grid layouts, per-type filter,
free-text search, neighbour highlight, clickable cross-links and backlinks.
The default layout is force (cose) up to AUTO_COSE_MAX concepts, then the linear
concentric layout — force-directed cost grows roughly quadratically with node
count and freezes the page on large bundles. An explicit --layout always wins.
Run: uv run okf_visualize.py <bundle-dir> [-o viz.html]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
import yaml
RESERVED = {"index.md", "log.md"}
# Force (cose) layout froze the page for ~32 s at ~2k concepts (measured in
# Chrome); the linear layouts load the same bundle in under 2 s. Past this size
# the default switches to concentric, and the in-page layout picker asks before
# running force. An explicit --layout (or ?layout=) still wins.
AUTO_COSE_MAX = 1000
# Above this the page is slow on any layout (23k concepts measured: ~27 s load,
# ~650 MB heap) and reads as a hairball — warn and suggest rendering a subtree.
SCALE_WARN = 5000
FENCE = re.compile(r"^(```|~~~)")
LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def json_for_script(obj) -> str:
"""JSON-encode ``obj`` for safe embedding inside an inline ``<script>``.
The HTML tokenizer knows nothing about JS string context: a literal
``</script>`` in a concept body ends the inline script early (truncating the
embedded ``NODES``/``EDGES`` and breaking the whole page), and ``<!--`` can
shift it into the escaped script-data states where even the template's real
closing tag stops working. Escaping every ``<`` as ``\\u003c`` neutralizes
``</script>``, ``<!--`` and ``<script`` in one stroke, and the result stays
valid JSON *and* JavaScript (``json.dumps`` keeps everything else ASCII via
the default ``ensure_ascii=True``).
"""
return json.dumps(obj, default=str).replace("<", "\\u003c")
def split_frontmatter(text: str):
if not text.startswith("---"):
return {}, text
lines = text.splitlines(keepends=True)
if lines[0].strip() != "---":
return {}, text
for i in range(1, len(lines)):
if lines[i].strip() == "---":
try:
meta = yaml.safe_load("".join(lines[1:i])) or {}
except yaml.YAMLError:
meta = {}
return (meta if isinstance(meta, dict) else {}), "".join(lines[i + 1:])
return {}, text
def link_targets(text: str):
out, in_fence = [], False
for line in text.splitlines():
if FENCE.match(line.strip()):
in_fence = not in_fence
continue
if not in_fence:
out.extend(LINK.findall(line))
return out
def resolve(target: str, path: Path, bundle: Path):
"""Resolve a link/`sources[].resource` to a concept id, or None if it is not
one (an external URL, an asset, a scope descriptor, an escape from the tree)."""
t = str(target).split("#", 1)[0]
if not t.endswith(".md"):
return None
if t.startswith("/"):
return t.lstrip("/")[:-3]
cand = (path.parent / t).resolve()
return cand.relative_to(bundle.resolve()).as_posix()[:-3] \
if cand.is_relative_to(bundle.resolve()) else None
def read_sources(meta: dict, path: Path, bundle: Path):
"""§5.1 `sources`, flattened for display. `cid` is set when the source is
itself a concept in this bundle — that derivation is a real graph edge."""
raw = meta.get("sources")
out = []
if not isinstance(raw, list):
return out
for src in raw:
if not isinstance(src, dict):
continue
resource = str(src.get("resource", "")).strip()
# §5.1 — an entry's own `usage_window` overrides the one written once as a
# sibling of `sources`. A count without its window has no units.
window = src.get("usage_window", meta.get("usage_window"))
out.append({
"title": str(src.get("title") or src.get("id") or resource),
"resource": resource,
"cid": resolve(resource, path, bundle) if resource else None,
"author": str(src.get("author", "")),
"usage_count": src.get("usage_count"),
"usage_window": (f"{window.get('from', '?')}→{window.get('to', '?')}"
if isinstance(window, dict) else ""),
"last_modified": str(src.get("last_modified") or ""),
})
return out
def read_trust(meta: dict):
"""§5.2 `generated` / `verified`, falling back to a v0.1 `timestamp` (§13.1)."""
gen = meta.get("generated")
if isinstance(gen, dict):
generated = {"by": str(gen.get("by", "")), "at": str(gen.get("at", ""))}
elif meta.get("timestamp"):
generated = {"by": "", "at": str(meta["timestamp"])}
else:
generated = None
ver = meta.get("verified")
# a bare mapping is one verification event (§5.2)
entries = [ver] if isinstance(ver, dict) else (ver if isinstance(ver, list) else [])
verified = [{"by": str(e.get("by", "")), "at": str(e.get("at", ""))}
for e in entries if isinstance(e, dict)]
return generated, verified
def build(bundle: Path):
nodes, edges, seen = [], [], set()
files = sorted(p for p in bundle.rglob("*.md") if p.is_file() and p.name not in RESERVED)
ids = {p.relative_to(bundle).with_suffix("").as_posix() for p in files}
for p in files:
cid = p.relative_to(bundle).with_suffix("").as_posix()
try:
raw = p.read_text(encoding="utf-8").lstrip("")
except (UnicodeDecodeError, OSError) as exc:
print(f"warning: skipping {p.relative_to(bundle)}: cannot read file: {exc}", file=sys.stderr)
ids.discard(cid)
continue
meta, body = split_frontmatter(raw)
body = body.strip()
generated, verified = read_trust(meta)
sources = read_sources(meta, p, bundle)
nodes.append({
"id": cid,
"type": str(meta.get("type", "Untyped")),
"title": str(meta.get("title", p.stem)),
"description": str(meta.get("description", "")),
"tags": meta.get("tags", []) if isinstance(meta.get("tags"), list) else [],
"group": cid.split("/")[0] if "/" in cid else "(root)",
"sz": max(24, min(70, 24 + len(body) // 200)),
"status": str(meta.get("status", "")),
"stale_after": str(meta.get("stale_after") or ""),
"generated": generated,
"verified": verified,
"sources": sources,
"body": body[:8000],
})
targets = link_targets(body) + [s["resource"] for s in sources if s["cid"]]
for t in targets:
tgt = resolve(t, p, bundle)
if tgt and tgt in ids and tgt != cid and (cid, tgt) not in seen:
seen.add((cid, tgt))
edges.append({"source": cid, "target": tgt})
return nodes, edges
HTML = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OKF — __NAME__</title>
<meta property="og:title" content="__OGTITLE__">
<meta property="og:description" content="__OGDESC__">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
__OGIMAGE__
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@14/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js" integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu" crossorigin="anonymous"></script>
<style>
:root{--bg:#0e0f13;--panel:#16181f;--line:#262a35;--fg:#e6e8ee;--mut:#9aa3b2;--accent:#8ab4ff}
*{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
/* The row must be stated. Left implicit it sizes to `auto`, i.e. to the tallest
item — a long concept body then stretched #side past the viewport and the page
grew its own scrollbar on top of the panel's, which in turn forced a spurious
horizontal one. Pinning the row to 100% keeps the panel scrolling inside. */
#app{display:grid;grid-template-columns:1fr 400px;grid-template-rows:100%;height:100vh}
#cy{width:100%;height:100%}
#side{border-left:1px solid var(--line);background:var(--panel);overflow:auto;padding:18px}
header{position:absolute;top:0;left:0;padding:14px 18px;z-index:5;pointer-events:none}
h1{font-size:15px;margin:0;font-weight:650} .sub{color:var(--mut);font-size:12px;margin-top:2px}
#bar{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:flex;gap:8px}
#bar input,#bar select{background:var(--panel);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:8px 10px;outline:none;font-size:13px}
#search{width:min(300px,32vw)}
#legend{position:absolute;bottom:14px;left:18px;z-index:5;display:flex;flex-wrap:wrap;gap:6px;max-width:60vw}
.chip{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);
border-radius:20px;padding:3px 10px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
.chip.off{opacity:.4} .dot{width:10px;height:10px;border-radius:50%}
#side h2{font-size:17px;margin:.2em 0} .type{display:inline-block;border-radius:6px;padding:2px 8px;
font-size:11px;font-weight:600;color:#0e0f13} .desc{color:var(--mut);margin:8px 0 12px}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.tag{background:#1d2230;border:1px solid var(--line);border-radius:6px;padding:1px 8px;font-size:11px;color:var(--mut)}
.meta{margin:10px 0;font-size:12px;color:var(--mut)} .meta div{padding:1px 0} .meta b{color:var(--fg);font-weight:600}
.sig{color:var(--mut)}
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.badge{border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;border:1px solid}
.t-unverified{color:#9aa3b2;border-color:#3a4150;background:#1d2230}
.t-machine{color:#8ab4ff;border-color:#2b4570;background:#141c2b}
.t-human{color:#4ade80;border-color:#276b45;background:#12211a}
.b-stale{color:#fca5a5;border-color:#7f2b2b;background:#241416}
.b-deprecated{color:#fbbf24;border-color:#7a5312;background:#241d10}
.rel{margin:10px 0} .rel h4{margin:0 0 4px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
.rel a{display:block;color:var(--accent);cursor:pointer;font-size:13px;padding:1px 0;text-decoration:none}
.rel a:hover{text-decoration:underline}
.body{border-top:1px solid var(--line);padding-top:12px;margin-top:12px} .body table{border-collapse:collapse;display:block;overflow:auto}
.body td,.body th{border:1px solid var(--line);padding:4px 8px} .body code{background:#1d2230;padding:1px 5px;border-radius:4px}
.body pre{background:#1d2230;padding:10px;border-radius:8px;overflow:auto} .body img{max-width:100%}
.empty{color:var(--mut)} a{color:var(--accent)}
.src{pointer-events:auto;color:var(--accent);margin-left:10px;text-decoration:none} .src:hover{text-decoration:underline}
</style></head><body>
<div id="app"><div id="cy"></div><div id="side"><p class="empty">Click a concept to inspect it.</p></div></div>
<header><h1>__NAME__</h1><div class="sub">__N__ concepts · __E__ links · OKF v0.2__LINK__</div></header>
<div id="bar">
<input id="search" placeholder="search concepts…">
<select id="type"><option value="">all types</option></select>
<select id="layout">
<option value="cose">force</option><option value="concentric">concentric</option>
<option value="breadthfirst">breadth-first</option><option value="circle">circle</option><option value="grid">grid</option>
</select>
</div>
<div id="legend"></div>
<script>
const NODES=__NODES__, EDGES=__EDGES__;
const PALETTE=["#6E56CF","#D97757","#22C55E","#3B82F6","#EAB308","#EC4899","#14B8A6","#F97316","#A855F7","#0EA5E9","#84CC16","#EF4444","#64748B"];
const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
const outL={}, inL={};
NODES.forEach(n=>{outL[n.id]=[];inL[n.id]=[];});
EDGES.forEach(e=>{outL[e.source].push(e.target);inL[e.target].push(e.source);});
const types=[...new Set(NODES.map(n=>n.type))].sort();
const color=Object.fromEntries(types.map((t,i)=>[t,PALETTE[i%PALETTE.length]]));
const off=new Set();
const cy=cytoscape({container:document.getElementById('cy'),minZoom:.2,maxZoom:1.6,wheelSensitivity:.2,
elements:[...NODES.map(n=>({data:{...n,c:color[n.type]}})),...EDGES.map(e=>({data:e}))],
style:[
{selector:'node',style:{'background-color':'data(c)','label':'data(title)','color':'#e6e8ee',
'font-size':10,'text-wrap':'wrap','text-max-width':120,'text-valign':'bottom','text-margin-y':4,
'text-outline-width':2,'text-outline-color':'#0e0f13','min-zoomed-font-size':6,
'width':'data(sz)','height':'data(sz)'}},
{selector:'edge',style:{'width':1.2,'line-color':'#3a4150','target-arrow-color':'#3a4150',
'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.7}},
{selector:'.dim',style:{'opacity':.10}},{selector:'.hl',style:{'border-width':3,'border-color':'#fff'}}
],
layout:{name:'__LAYOUT__',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40}});
const side=document.getElementById('side');
const esc=s=>(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function relList(title,arr){if(!arr.length)return'';
return `<div class="rel"><h4>${title}</h4>${arr.map(id=>`<a data-go="${esc(id)}">${esc((byId[id]||{}).title||id)}</a>`).join('')}</div>`;}
// §5.3 — the trust tier is derived, never stored: no `verified` is unverified,
// `verified` by non-`human:` actors only is machine-confirmed, and any `human:`
// actor makes it human-reviewed. The exact lowercase prefix is the whole key.
// §5.5 — a concept is stale when now >= stale_after. PyYAML renders an unquoted
// datetime with a space separator, which Date.parse rejects in some engines.
// Advisory signals, not access control.
function trustTier(n){const v=n.verified||[];
if(!v.length)return['t-unverified','unverified'];
return v.some(e=>(e.by||'').startsWith('human:'))?['t-human','human-reviewed']
:['t-machine','machine-confirmed'];}
function badges(n){const [cls,label]=trustTier(n),out=[`<span class="badge ${cls}">${label}</span>`];
if(n.stale_after&&Date.parse(n.stale_after.replace(' ','T'))<=Date.now())out.push(`<span class="badge b-stale">stale since ${esc(n.stale_after)}</span>`);
if(n.status==='deprecated')out.push('<span class="badge b-deprecated">deprecated</span>');
return `<div class="badges">${out.join('')}</div>`;}
// OKF v0.2 trust (§5.2) + lifecycle (§5.4/§5.5). A v0.1 `timestamp` arrives here
// as generated.at with an empty `by`, so legacy bundles still show a date.
function metaBlock(n){const g=n.generated||{},rows=[];
if(n.status)rows.push(`<div>status <b>${esc(n.status)}</b></div>`);
if(g.at||g.by)rows.push(`<div>generated${g.at?` <b>${esc(g.at)}</b>`:''}${g.by?` by ${esc(g.by)}`:''}</div>`);
(n.verified||[]).forEach(v=>rows.push(`<div>verified${v.at?` <b>${esc(v.at)}</b>`:''}${v.by?` by ${esc(v.by)}`:''}</div>`));
if(n.stale_after)rows.push(`<div>stale after <b>${esc(n.stale_after)}</b></div>`);
return rows.length?`<div class="meta">${rows.join('')}</div>`:'';}
// Provenance (§5.1). A source may be another concept (graph link), an external
// URL, or a scope descriptor that is not followable at all.
function srcList(n){const s=n.sources||[];if(!s.length)return'';
return `<div class="rel"><h4>Sources</h4>${s.map(x=>{
const used=x.usage_count!=null?`used ${x.usage_count}×${x.usage_window?` (${x.usage_window})`:''}`:'';
const sig=[x.author,used,x.last_modified].filter(Boolean).join(' · ');
const label=esc(x.title||x.resource)+(sig?` <span class="sig">(${esc(sig)})</span>`:'');
if(x.cid&&byId[x.cid])return `<a data-go="${esc(x.cid)}">${label}</a>`;
if(/^https?:\/\//i.test(x.resource))return `<a href="${esc(x.resource)}" target="_blank" rel="noopener">${label}</a>`;
return `<span class="empty">${label}</span>`;}).join('')}</div>`;}
// Resolve an in-body markdown link href to a concept id, mirroring build()'s
// resolution: strip #anchor, require .md, absolute strips leading /, relative
// resolves against the current concept's dir. Returns null if it's not a concept.
function resolveHref(cid,href){let t=(href||'').split('#')[0];if(!t.endsWith('.md'))return null;
let tgt;if(t[0]==='/'){tgt=t.replace(/^\/+/,'').slice(0,-3);}
else{const base=cid.split('/').slice(0,-1);
for(const seg of t.slice(0,-3).split('/')){if(seg===''||seg==='.')continue;
if(seg==='..'){if(!base.length)return null;base.pop();}else base.push(seg);}
tgt=base.join('/');}
return byId[tgt]?tgt:null;}
function show(id){const n=byId[id];if(!n)return;const c=color[n.type];
side.innerHTML=`<span class="type" style="background:${c}">${esc(n.type)}</span>
<h2>${esc(n.title)}</h2><div class="desc">${esc(n.description)||'<span class=empty>no description</span>'}</div>
<div class="tags">${(n.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
${badges(n)}${metaBlock(n)}${srcList(n)}${relList('Links to',outL[id])}${relList('Cited by',inL[id])}
<div class="body">${n.body?DOMPurify.sanitize(marked.parse(n.body)):'<span class=empty>empty body</span>'}</div>`;
side.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
side.querySelectorAll('.body a[href]').forEach(a=>{const tgt=resolveHref(id,a.getAttribute('href'));
if(tgt)a.onclick=e=>{e.preventDefault();select(tgt);};});}
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;show(id);
cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
cy.animate({center:{eles:ele},duration:250});
try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
cy.on('tap','node',e=>select(e.target.id()));
cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
function applyFilter(){const q=document.getElementById('search').value.toLowerCase();const ty=document.getElementById('type').value;
cy.batch(()=>cy.nodes().forEach(n=>{const d=n.data();
const m=(!q||(d.title+' '+d.type+' '+d.description+' '+(d.tags||[]).join(' ')).toLowerCase().includes(q))
&&(!ty||d.type===ty)&&!off.has(d.type);
n.style('display',m?'element':'none');}));}
let debounce;
document.getElementById('search').oninput=()=>{clearTimeout(debounce);debounce=setTimeout(applyFilter,150);};
document.getElementById('type').oninput=applyFilter;
const tysel=document.getElementById('type');types.forEach(t=>{const o=document.createElement('option');o.value=t;o.textContent=t;tysel.appendChild(o);});
let curLayout='__LAYOUT__';
document.getElementById('layout').onchange=e=>{const v=e.target.value;
if(v==='cose'&&NODES.length>__COSEMAX__&&!confirm(`force layout on ${NODES.length} concepts can freeze this tab — run anyway?`)){e.target.value=curLayout;return;}
curLayout=v;cy.layout({name:v,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();};
document.getElementById('legend').innerHTML=types.map(t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} (${NODES.filter(n=>n.type===t).length})</span>`).join('');
document.querySelectorAll('#legend .chip').forEach(ch=>ch.onclick=()=>{const t=ch.getAttribute('data-t');
if(off.has(t)){off.delete(t);ch.classList.remove('off');}else{off.add(t);ch.classList.add('off');}applyFilter();});
document.getElementById('layout').value='__LAYOUT__';
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
if(QL&&[...document.querySelectorAll('#layout option')].some(o=>o.value===QL)){document.getElementById('layout').value=QL;curLayout=QL;cy.layout({name:QL,animate:false,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();}
function fromHash(){try{const h=decodeURIComponent((location.hash||'').slice(1));if(h&&byId[h])select(h);}catch(e){}}
addEventListener('hashchange',fromHash);
if(QS&&byId[QS])select(QS);else fromHash();
</script></body></html>"""
def render(bundle: Path, out: Path, title: str | None = None, link: str | None = None,
layout: str | None = None, og_image: str | None = None,
max_nodes: int | None = None):
nodes, edges = build(bundle)
if max_nodes is not None and len(nodes) > max_nodes:
sys.exit(f"error: {len(nodes)} concepts exceeds --max-nodes {max_nodes}")
if layout is None:
layout = "cose" if len(nodes) <= AUTO_COSE_MAX else "concentric"
if layout != "cose":
print(f"note: {len(nodes)} concepts > {AUTO_COSE_MAX} — using the linear 'concentric' "
"layout (force freezes the page at this size; pass --layout cose to override)",
file=sys.stderr)
if len(nodes) > SCALE_WARN:
print(f"warning: {len(nodes)} concepts — the page will load slowly and read as a hairball; "
f"consider rendering a subtree, e.g. okf_visualize.py {bundle}/<subdir>",
file=sys.stderr)
name = title or f"{bundle.resolve().parent.name}/{bundle.name}"
src = f' <a class="src" href="{link}" target="_blank" rel="noopener">source ↗</a>' if link else ""
aesc = lambda s: (s or "").replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
og_title = aesc(f"OKF — {name}")
og_desc = aesc(f"{len(nodes)} concepts · interactive Open Knowledge Format knowledge graph")
og_img = (f'<meta property="og:image" content="{aesc(og_image)}">\n'
f'<meta name="twitter:image" content="{aesc(og_image)}">') if og_image else ""
subs = {"__NAME__": name, "__LINK__": src, "__LAYOUT__": layout,
"__COSEMAX__": str(AUTO_COSE_MAX),
"__OGTITLE__": og_title, "__OGDESC__": og_desc, "__OGIMAGE__": og_img,
"__N__": str(len(nodes)), "__E__": str(len(edges)),
"__NODES__": json_for_script(nodes), "__EDGES__": json_for_script(edges)}
# One pass, longest marker first: substituted content (e.g. a concept body that
# mentions "__EDGES__") must never itself be rescanned for other markers.
marker = re.compile("|".join(sorted(map(re.escape, subs), key=len, reverse=True)))
html = marker.sub(lambda m: subs[m.group(0)], HTML)
out.write_text(html, encoding="utf-8")
return len(nodes), len(edges)
def _force_utf8_stdio() -> None:
"""Reconfigure stdout/stderr to UTF-8 so status lines never crash on a
cp1252 (default Windows) console. `errors="replace"` is a fallback."""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError):
pass
def main() -> int:
_force_utf8_stdio()
ap = argparse.ArgumentParser(description="Render an OKF bundle as a self-contained HTML graph.")
ap.add_argument("bundle", type=Path)
ap.add_argument("-o", "--out", type=Path, default=None)
ap.add_argument("-t", "--title", default=None, help="graph title (default: parent/bundle dir name)")
ap.add_argument("-l", "--link", default=None, help="optional source URL shown in the header")
ap.add_argument("--layout", default=None,
choices=["cose", "concentric", "breadthfirst", "circle", "grid"],
help=f"initial graph layout (default: cose, or concentric above {AUTO_COSE_MAX} "
"concepts — force layout freezes the page on large bundles)")
ap.add_argument("--max-nodes", type=int, default=None,
help="refuse to render bundles with more concepts than this (useful in CI)")
ap.add_argument("--og-image", default=None,
help="absolute URL for the social-preview image (og:image / twitter:image)")
args = ap.parse_args()
if not args.bundle.is_dir():
print(f"error: {args.bundle} is not a directory", file=sys.stderr)
return 2
out = args.out or (args.bundle / "viz.html")
n, e = render(args.bundle, out, title=args.title, link=args.link, layout=args.layout,
og_image=args.og_image, max_nodes=args.max_nodes)
print(f"rendered {n} concepts, {e} links -> {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())