feat(linkedin-studio): N13 — publiserings-slots + vacancy-varsel + WIP-roll-up + slot-default [skip-docs]

A1-10 + D-5. The queue answers "what is scheduled"; nothing answered "is the week
covered, and what is still being written". N13 makes both readable at session start.

- Publishing slots are operator config: profile/publishing-slots.json in the per-user
  data dir, schema + OPT-IN template in config/publishing-slots.template.json. The
  plugin ships no publishing times and writes the grid for nobody; absent config means
  every slot surface stays silent (same never-nag discipline as the trend/brain nudges).
  Data dir rather than the state file, which is machine-written and whose frontmatter
  reader does not parse lists.
- hooks/scripts/slots.mjs (new): one implementation behind four surfaces. Zero-dep (a
  SessionStart hook must not spawn tsx), pure core (dates in as strings, no clock),
  imported by the commands exactly as queue-manager.mjs already is — so session-start's
  vacancy warning and Step 10's slot default cannot drift apart.
- Coverage counts the short-form queue (scheduled/published; cancelled frees the slot)
  AND editions-register rows claiming that date, counted per date, so long-form and
  short-form cannot be double-booked and a two-slot day needs two posts.
- Session start gains "## Editions in Flight" (series, phase, next action, claimed slot,
  days in flight) and "## Publishing Slots" (next open slot, open count in 14 days, and
  how to fill it when the gap is <=3 days). The existing discovery nudge is untouched.
- /linkedin router + /linkedin:calendar surface the same roll-up; calendar documents the
  opt-in copy. /linkedin:newsletter Step 10 defaults to the next open slot (one
  confirmation, not an interview) and writes --slot onto the register row.
- D-5: references/scheduling-strategy.md marked "confidence: low / directional" per the
  algorithm reference's own standard, deferring to the operator's grid and their own
  analytics. Neutral example time replaces the operator-specific one in the N12 docs.

TDD: red proven first (module absent; 5 session-start behaviours failing).
hooks 140 -> 174 · test-runner 184 -> 197 (Section 16t: 13 unconditional greps incl. a
de-niche check that no publishing time is hardcoded in the slot path and no operator grid
is committed, + non-vacuity self-test; anti-erosion floor 166 -> 179).
Suites green: trends 300/0 · brain 134/0 · editions 72/0 · specifics-bank 45/0 ·
tests 35/0 · render 60/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 07:28:48 +02:00
commit 677eab9294
14 changed files with 1069 additions and 19 deletions

View file

@ -52,6 +52,41 @@ The `queueFormatSummary` blocks are the human-readable overview; the **ENTRY REC
Also read state for context:
- `~/.claude/linkedin-studio.local.md` for weekly goal and current progress
## Step 1b: Load Publishing Slots + Editions in Flight
The queue says what is scheduled. The **slot grid** says what the week is supposed to
hold, and the **editions register** says which long-form pieces are still in production —
together they turn "here are some posts" into a capacity picture:
```bash
node --input-type=module -e "
import { readSlots, slotVacancies, slotOccurrences, formatSlot, defaultSlotsPath, activeEditions } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs';
const grid = readSlots();
console.log('=== SLOT GRID ===');
console.log(grid.slots.length ? grid.slots.map(s => s.day + ' ' + s.time + (s.label ? ' | ' + s.label : '')).join('\n') : 'NOT CONFIGURED: ' + defaultSlotsPath());
console.log('=== OPEN SLOTS (next 14 days) ===');
console.log(slotVacancies({ days: 14 }).map(s => formatSlot(s)).join('\n') || 'none - every planned slot is covered');
console.log('=== PLANNED SLOTS (next 14 days) ===');
console.log(String(slotOccurrences(grid.slots, new Date().toISOString().slice(0,10), 14).length));
console.log('=== EDITIONS IN FLIGHT ===');
console.log(activeEditions().map(e => e.series + ' #' + e.editionId + ' | ' + e.currentPhase + ' | next: ' + (e.nextAction || 'unset') + ' | slot: ' + (e.slot || 'unclaimed') + ' | ' + e.daysInFlight + 'd in flight').join('\n') || 'none');
"
```
**If the grid is NOT CONFIGURED**, say so once and offer the opt-in — the plugin
deliberately imposes no schedule:
```
No publishing slots configured. To get slot-vacancy warnings at session start and a
one-key slot default in /linkedin:newsletter, copy the template and edit the times:
cp "${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json" \
"${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json"
```
The template's times are the plugin's **directional** starting point (see
`references/scheduling-strategy.md`), not a recommendation about your audience — replace
them with what `/linkedin:report` shows for your own account.
## Step 2: Display Calendar View
Present a 14-day calendar view:
@ -77,6 +112,16 @@ Format mix: X standard, Y carousel, Z quick
Pillars: [pillar counts]
```
Mark each configured slot on its day, so an empty slot is visible as a hole rather than
inferred from an absence: `Tue [date]: [SLOT 08:30 — OPEN]` when nothing covers it,
`[SLOT 08:30 — covered]` when the queue or an edition does. Below the calendar, add the
capacity lines from Step 1b:
```
Slots: X planned in 14 days | Y open — next open: [Thu YYYY-MM-DD 12:00 (in N days)]
Editions in flight: [series #NN — phase → next action (Nd)] (or "none")
```
If there are **overdue** posts (past scheduled date, still "scheduled"), highlight them:
```
OVERDUE:
@ -207,9 +252,15 @@ After showing the calendar (or after a publish action loops back), provide brief
- **Pillar balance**: Are pillars well-distributed? Flag if any pillar >50%.
- **Gap detection**: Are there multi-day gaps that could hurt momentum?
- **Weekly goal alignment**: Will the schedule meet the weekly goal?
- **Slot vacancy**: Any open slot within 3 days is the most actionable thing on the
screen — name it and offer `/linkedin:batch` (short-form) or `/linkedin:newsletter`
(long-form). An edition already in flight with no claimed slot is the natural filler.
## Reference Files
- `${CLAUDE_PLUGIN_ROOT}/references/scheduling-strategy.md`
- `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md`
- `${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json` (slot-grid schema + opt-in)
- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json`
- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json` (your grid)
- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json` (editions in flight)

View file

@ -56,6 +56,32 @@ OVERDUE (should have been posted):
If queue is empty: "No posts scheduled. Run /linkedin:batch to plan your week."
## Work in Progress + Open Slots
The queue only shows finished posts waiting to go out. Add the production picture —
long-form editions still being written, and publishing slots nothing covers yet:
```bash
node --input-type=module -e "
import { activeEditions, slotVacancies, formatSlot, readSlots } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs';
console.log('=== IN FLIGHT ===');
console.log(activeEditions().map(e => e.series + ' #' + e.editionId + ' | ' + e.currentPhase + ' | next: ' + (e.nextAction || 'unset') + ' | ' + e.daysInFlight + 'd').join('\n') || 'none');
console.log('=== OPEN SLOTS ===');
console.log(readSlots().slots.length ? (slotVacancies({ days: 14 }).map(s => formatSlot(s)).join('\n') || 'none - every planned slot is covered') : 'grid not configured');
"
```
Show one line per in-flight edition and the next open slot:
```
In flight: [series #NN] — [phase] → [next action] ([N]d)
Next open slot: [Thu YYYY-MM-DD 12:00 (in N days)] — [X] open in 14 days
```
Both lines are omitted when there is nothing to report (no active editions / no grid
configured). If the grid is not configured, do **not** nag — `/linkedin:calendar`
explains the opt-in when the user goes there.
## Available Commands
LinkedIn Studio is organized as **five journeys**. Each journey has a **front-door**
@ -165,6 +191,7 @@ If the user's intent is clear from context:
- Mentions "trends" or "trending" or "discovery pass" or "morning brief" or "what should I write about" → Route to `/linkedin:trends`
- Mentions "batch" or "week of content" → Route to `/linkedin:batch`
- Mentions "calendar" or "schedule" or "queue" or "upcoming posts" or "what's scheduled" → Route to `/linkedin:calendar`
- Mentions "slot" or "publishing slots" or "empty slot" or "open slot" or "slot grid" or "what's in flight" or "work in progress" → Route to `/linkedin:calendar` (slot grid, vacancies, editions in flight)
- Mentions "publish" or "mark as published" or "posted today" or "just published" or "post is live" → Route to `/linkedin:calendar` (publish action)
- Mentions "plan" → Suggest `content-planner` agent
- Mentions "profile" or "topic-relevance" → Route to `/linkedin:profile`

View file

@ -2080,9 +2080,24 @@ now a first-class scheduled post.
**Procedure:**
1. **Pick the slot.** Confirm the target `scheduled_date` (YYYY-MM-DD) and
`scheduled_time` from the edition-config calendar / the series brief. If the
slot is unset, ask once (Step 1's calibration may already have settled it).
1. **Pick the slot — default to the next open one (N13).** Read the operator's
publishing grid and take the first slot nothing covers yet, so scheduling is
one confirmation rather than an interview:
```bash
node --input-type=module -e "
import { nextFreeSlot, formatSlot, readSlots } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs';
const s = readSlots().slots.length ? nextFreeSlot({ days: 28 }) : null;
console.log(s ? s.date + ' ' + s.time + ' (' + formatSlot(s) + ')' : 'NO SLOT');
"
```
Propose that date/time and ask **once** for a yes/no. `NO SLOT` means the grid
is unconfigured or every slot in the next 28 days is taken — then fall back to
the edition-config calendar / series brief, and ask for the slot outright
(Step 1's calibration may already have settled it). A slot the operator
overrides is simply used as given; the default is a starting point, never a
constraint.
2. **Register via `queue-manager.mjs`.** Add one queue entry for the edition,
reusing the short-form queue contract — `queueAdd(id, draftPath, schedDate,
@ -2119,9 +2134,12 @@ now a first-class scheduled post.
`edition-state.json`. Write a closing "edition scheduled → mark live via
`/linkedin:calendar` on <date>" line to `<serie>/STATE.md` (overwrite). The
pipeline is complete.
**↳ Phase transition** — run the protocol (Step 0) for `scheduling`,
`--next "published — mark live via /linkedin:calendar"`, and then **close the
register row** (this step alone does):
**↳ Phase transition** — run the protocol (Step 0) for `scheduling`, this time
**with the slot** (`--slot "<YYYY-MM-DD HH:MM>"`, the one confirmed in step 1)
and `--next "published — mark live via /linkedin:calendar"`. The slot on the row
is what makes the edition visible as *coverage* of that publishing slot, so the
next session stops offering it as open. Then **close the register row** (this
step alone does):
```bash
node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \
@ -2214,6 +2232,7 @@ the honest decision surface; it sells nothing.
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`)
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger), `record-usage` (Step 8 lock — «used in edition NN»). Design record: `docs/fix2/slice2-binding.md`
- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking. **Editions register (N12):** `register-upsert` at every phase transition (appends `articles.NN.phaseLog` + mirrors the row into `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`), `register-list` for the work-in-progress view, `register-complete` at Step 10 (measured lead time). The register mirrors state — resumption never reads it
- `${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs` — the publishing grid (N13): `nextFreeSlot` gives Step 10 its default slot (queue + register coverage), and session start reports open slots from the same functions. Zero-dep, so hook and command can never disagree. The grid itself is operator config (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json`); unconfigured means Step 10 simply asks
- `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8
- `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5
- `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9