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:
parent
657539ef09
commit
677eab9294
14 changed files with 1069 additions and 19 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -49,6 +49,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Deterministic, no clock in the core** — every mutation takes `now` as an argument (`--at` / `--now` at the CLI edge), as the distillate takes `lockedAt`. Idempotent where a re-run is legitimate (a repeated transition logs once; completing twice keeps the first `completedAt`), *not* where it is real work (a phase recurring after another one is logged again — a pivot back through cleared gates is production time that happened). Missing facts are refused at the edge rather than defaulted: a row naming the wrong phase is worse than no row.
|
||||
- Suites: editions 27 → 72; test-runner 173 → 184 (Section 16s: 11 unconditional greps incl. a 16-phase coverage sweep + non-vacuity self-test; anti-erosion floor 155 → 166). `tsc --noEmit` clean. Domain-general throughout.
|
||||
|
||||
### Added — publishing slots + vacancy warning + WIP roll-up (N13 / A1-10, D-5)
|
||||
|
||||
- **Publishing slots are operator config (`profile/publishing-slots.json`)** — a recurring weekly grid (`{day, time, label}`) in the per-user data dir, with a schema + **opt-in** template at `config/publishing-slots.template.json`. The plugin writes it for nobody and ships no publishing times: while the file is absent every slot surface stays silent, exactly like the trend and brain nudges. It lives in the data dir rather than the state file because the state file is machine-written (rollover, pruning, post tracking) and its frontmatter reader does not parse lists — user config does not belong in a file the plugin rewrites.
|
||||
- **Vacancy at session start** — the SessionStart hook now names the next publishing slot **nothing covers**, how many are open in the next 14 days, and — when the gap is ≤3 days — how to fill it. Coverage counts both the short-form queue (status `scheduled`/`published`; a cancelled entry frees the slot again) **and** editions-register rows whose slot claims that date, so long-form and short-form can never be booked on top of each other. A day carrying two slots needs two posts to be full.
|
||||
- **Work in progress is surfaced, not searched for** — `## Editions in Flight` at session start, plus the same roll-up in the `/linkedin` router and `/linkedin:calendar`: series, edition, current phase, the one next action, claimed slot, and days in flight. The N12 register made this measurable; N13 makes it *visible without asking*.
|
||||
- **`hooks/scripts/slots.mjs` — one implementation, four surfaces** — zero-dep (a SessionStart hook must not spawn tsx), pure core (dates in as strings, no clock), and imported by the commands the same way `queue-manager.mjs` already is. The hook's vacancy warning and the slot `/linkedin:newsletter` defaults to therefore cannot disagree.
|
||||
- **`/linkedin:newsletter` Step 10 defaults to the next open slot** — scheduling is one confirmation instead of an interview, and the transition now writes `--slot` onto the register row so a scheduled edition stops reading as an open slot. Unconfigured grid or a full horizon ⇒ the old ask, unchanged.
|
||||
- **D-5: the shipped schedule is marked for what it is** — `references/scheduling-strategy.md` now carries the same epistemic standard as the algorithm reference (**confidence: low / directional**, practitioner heuristic, no primary source) and defers to the operator's own grid, tested against their own analytics. The tables are a starting point, not a finding about anyone's audience.
|
||||
- Suites: hooks 140 → 174 (25 slot-core + 9 session-start tests); 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).
|
||||
|
||||
### Built feedback (awaiting consumer-side proof)
|
||||
|
||||
- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
|
|||
- **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler. Consumption is logged back at Step 8 lock (`record-usage` → `usedIn`), so cross-edition re-use of the same material is visible rather than silently encouraged by the dedupe
|
||||
- **Series distillate:** `scripts/editions/` (TypeScript, needs `tsx` + `npm install`) — series-level memory: each locked edition's spent narrative units (anecdotes/arguments/hooks) in `<serie>/linkedin/series-distillate.json`; written at `/linkedin:newsletter` Step 8 (`distil-append`), checked against the next skeleton at Step 2.5 (`distil-check`, advisory). Deterministic character-trigram similarity — AI extracts, code compares
|
||||
- **Editions register:** `scripts/editions/` (same package) — one row per edition in production in the per-user data dir (`editions/register.json`): series, edition, path, current phase, next action, slot, `startedAt`/`completedAt`. Each of the 16 canonical `/linkedin:newsletter` phase transitions runs `register-upsert --edition-state <path>`, which appends `articles.NN.phaseLog` (lead-time telemetry, additive — schemaVersion stays 1) **and** mirrors the row in one call; Step 10 runs `register-complete`. `register-list` is the work-in-progress view. The register is a **mirror** — deterministic resumption reads `edition-state.json` only, and a lost register is rebuilt by the next transition
|
||||
- **Publishing slots:** `hooks/scripts/slots.mjs` — the weekly production grid. Slots are **operator config** in the per-user data dir (`profile/publishing-slots.json`, schema + opt-in template in `config/publishing-slots.template.json`); absent config ⇒ every slot surface stays silent, and the plugin hardcodes no publishing times. Coverage of a slot counts the short-form queue (`scheduled`/`published`) **and** editions-register rows claiming that date, so long-form and short-form cannot be double-booked. Zero-dep (SessionStart may not spawn tsx) and imported identically by the hook and by the commands (`queue-manager.mjs` pattern), so session-start's vacancy warning and `/linkedin:newsletter` Step 10's slot default are the same computation
|
||||
- **Contract-gate:** `scripts/contract-gate/` (TypeScript, needs `tsx` + `npm install`) — deterministic §B/§C1 rule-gate on the full draft (`/linkedin:newsletter` Step 4.5, before the AI sweeps); ratifies `rules.ts` against the edition's §E-manifest, then gates with BLOCK/WARN
|
||||
- **Analytics metrics (S16):** parsed CSV columns + an optional, manually-entered `saves` count (count-only in native LinkedIn analytics since ~Sept 2025, no CSV export; the Marketing API exposes `POST_SAVE` on `/memberCreatorPostAnalytics` from v202604, but access is partner-gated — so manual entry remains the right UX). `parseOptionalCount()`: blank / non-numeric / negative → `undefined` (`unknown`, never 0), a genuine `0` is kept; saves surfaced per-post + as `totalSaves`, but **not** folded into `engagementRate`. `dwell` stays **explicitly unmeasurable** (internal to LinkedIn, no export/API). All analytics I/O routes through the `getAnalyticsRoot()` seam (M0 per-user data-dir).
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
|
|||
|
||||
| Event | Purpose |
|
||||
|-------|---------|
|
||||
| `SessionStart` | Load state, REMEMBER.md, milestone tracker |
|
||||
| `SessionStart` | Load state, REMEMBER.md, milestone tracker, editions in flight + publishing-slot vacancy |
|
||||
| `PreToolUse` (Write\|Edit) | Content quality gate, voice guardian, topic rotation gate |
|
||||
| `Stop` | State update, pre-publish reminders, content history |
|
||||
| `UserPromptSubmit` | LinkedIn context enrichment (three-tier matching) |
|
||||
|
|
@ -31,7 +32,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
|
|||
| `PreCompact` | Preserve LinkedIn context during compaction |
|
||||
| `Notification` | Posting reminders (rate-limited 30min) |
|
||||
|
||||
**Session markers:** `/tmp/linkedin-hooks/session-active` (Stop hook gating, 12h staleness). **Shared modules:** `linkedin-content-filter.mjs`, `queue-manager.mjs`, `personalization-score.mjs`, `state-updater.mjs`, `clipboard-helper.mjs`.
|
||||
**Session markers:** `/tmp/linkedin-hooks/session-active` (Stop hook gating, 12h staleness). **Shared modules:** `linkedin-content-filter.mjs`, `queue-manager.mjs`, `personalization-score.mjs`, `state-updater.mjs`, `clipboard-helper.mjs`, `slots.mjs`.
|
||||
|
||||
**State updates:** post tracking, streak, and content history are handled deterministically by `state-updater.mjs` (Stop hook + commands); entries older than 90 days are auto-pruned at session start.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
config/publishing-slots.template.json
Normal file
16
config/publishing-slots.template.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"_doc": {
|
||||
"purpose": "Schema for publishing-slots.json (N13 / A1-10) — the recurring weekly slots you publish in. Session start reads it to name the next slot nothing covers yet, /linkedin:calendar shows the grid, and /linkedin:newsletter Step 10 defaults its scheduling to the next open slot instead of asking.",
|
||||
"install": "OPT-IN — the plugin never writes this file for you and imposes no schedule. Copy it into your data dir and edit the times: cp \"${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json\" \"${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json\". While the file is absent, every slot surface stays silent.",
|
||||
"slots": "Recurring weekly slots: day = a weekday, either three-letter (mon/tue/wed/thu/fri/sat/sun) or spelled out; time = 24-hour HH:MM (zero-padded). label is free text shown next to the slot (e.g. which format usually goes there). Unparseable rows are dropped rather than throwing — a typo costs you that slot, not your session start.",
|
||||
"timezone": "IANA zone the times are meant in, e.g. \"Europe/Oslo\" or \"America/New_York\". Recorded for your own clarity and displayed as-is; the plugin does not convert times (it schedules in your local wall clock, like the queue).",
|
||||
"coverage": "A slot counts as covered when something lands on that date: a queue entry with status scheduled or published (/linkedin:batch, /linkedin:quick, Step 10 of an edition), or an editions-register row whose slot claims that date. Cancelled entries free the slot again. A day carrying two slots needs two posts to be full.",
|
||||
"defaults": "The times below are the plugin's own DIRECTIONAL 2x/week starting point (references/scheduling-strategy.md — confidence: low / directional, no primary source). They are a hypothesis to test, not a recommendation about your audience. Replace them with what your own analytics show (/linkedin:report, /linkedin:analyze)."
|
||||
},
|
||||
"schemaVersion": 1,
|
||||
"timezone": "",
|
||||
"slots": [
|
||||
{ "day": "tue", "time": "08:30", "label": "" },
|
||||
{ "day": "thu", "time": "12:00", "label": "" }
|
||||
]
|
||||
}
|
||||
181
hooks/scripts/__tests__/session-start-slots.test.mjs
Normal file
181
hooks/scripts/__tests__/session-start-slots.test.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// N13 (A1-10 / A1-11 surfacing): the week's picture — which editions are in
|
||||
// flight and which publishing slots are still empty — must be readable at
|
||||
// session start without opening a file. session-start is a procedural hook with
|
||||
// no exports, so we run it as a subprocess with an isolated HOME +
|
||||
// LINKEDIN_STUDIO_DATA and inspect the additionalContext. Pattern:
|
||||
// session-start-trends-staleness.test.mjs subprocess harness.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const hookPath = join(here, '..', 'session-start.mjs');
|
||||
const PLUGIN_ROOT = join(here, '..', '..', '..');
|
||||
const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md');
|
||||
|
||||
const WIP_HEADER = '## Editions in Flight';
|
||||
const SLOTS_HEADER = '## Publishing Slots';
|
||||
const NEXT_OPEN = 'Next open slot:';
|
||||
|
||||
const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
const isoDaysAhead = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const isoDaysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
|
||||
const weekdayOf = (isoDate) => DAY_KEYS[new Date(`${isoDate}T12:00:00Z`).getUTCDay()];
|
||||
|
||||
// Seed an isolated data root, run the hook, return the injected context.
|
||||
function runHook({ slots, queue, editions, trends } = {}) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'lis-sl-home-'));
|
||||
const data = mkdtempSync(join(tmpdir(), 'lis-sl-data-'));
|
||||
try {
|
||||
// The status/reminders branch only runs when the state file exists; the
|
||||
// template is the canonical valid state.
|
||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
||||
copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md'));
|
||||
|
||||
if (slots) {
|
||||
mkdirSync(join(data, 'profile'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(data, 'profile', 'publishing-slots.json'),
|
||||
JSON.stringify({ schemaVersion: 1, slots }),
|
||||
);
|
||||
}
|
||||
if (queue) {
|
||||
mkdirSync(join(data, 'drafts'), { recursive: true });
|
||||
writeFileSync(join(data, 'drafts', 'queue.json'), JSON.stringify({ version: 1, queue }));
|
||||
}
|
||||
if (editions) {
|
||||
mkdirSync(join(data, 'editions'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(data, 'editions', 'register.json'),
|
||||
JSON.stringify({ schemaVersion: 1, editions }),
|
||||
);
|
||||
}
|
||||
if (trends) {
|
||||
mkdirSync(join(data, 'trends'), { recursive: true });
|
||||
writeFileSync(join(data, 'trends', 'trends.json'), JSON.stringify({ schemaVersion: 1, trends }));
|
||||
}
|
||||
|
||||
const stdout = execFileSync('node', [hookPath], {
|
||||
input: '',
|
||||
env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(data, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const activeRow = (over = {}) => ({
|
||||
series: 'serien',
|
||||
editionId: '05',
|
||||
title: 'En utgave',
|
||||
path: '/series/serien',
|
||||
currentPhase: 'draft',
|
||||
nextAction: 'Step 4 - full draft',
|
||||
slot: null,
|
||||
startedAt: new Date(Date.now() - 3 * 86400000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
status: 'active',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('session-start — editions in flight (N13/A1-11)', () => {
|
||||
test('surfaces active editions with phase, next action and days in flight', () => {
|
||||
const ctx = runHook({ editions: [activeRow()] });
|
||||
assert.ok(ctx.includes(WIP_HEADER), 'expected the in-flight roll-up');
|
||||
assert.ok(ctx.includes('serien #05'), 'expected the edition identity');
|
||||
assert.ok(ctx.includes('draft'), 'expected the current phase');
|
||||
assert.ok(ctx.includes('Step 4 - full draft'), 'expected the next action');
|
||||
assert.ok(/3d in flight/.test(ctx), 'expected measured days in flight');
|
||||
});
|
||||
|
||||
test('completed editions drop out — the view is work in progress, not history', () => {
|
||||
const ctx = runHook({
|
||||
editions: [activeRow({ editionId: '04', status: 'complete', completedAt: new Date().toISOString() })],
|
||||
});
|
||||
assert.ok(!ctx.includes(WIP_HEADER), 'a register with no active rows must not print the block');
|
||||
});
|
||||
|
||||
test('no register at all is silence, not an error', () => {
|
||||
const ctx = runHook({});
|
||||
assert.ok(!ctx.includes(WIP_HEADER), 'an unused register must not print anything');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-start — publishing-slot vacancy (N13/A1-10)', () => {
|
||||
test('names the next open slot when the grid has an uncovered day', () => {
|
||||
const target = isoDaysAhead(3);
|
||||
const ctx = runHook({ slots: [{ day: weekdayOf(target), time: '12:00', label: 'long-form' }] });
|
||||
assert.ok(ctx.includes(SLOTS_HEADER), 'expected the slots block');
|
||||
assert.ok(ctx.includes(NEXT_OPEN), 'expected the next open slot to be named');
|
||||
assert.ok(ctx.includes('long-form'), 'expected the operator label to carry through');
|
||||
});
|
||||
|
||||
test('an unconfigured grid says nothing at all — no default schedule is imposed', () => {
|
||||
const ctx = runHook({ editions: [activeRow()] });
|
||||
assert.ok(!ctx.includes(SLOTS_HEADER), 'no slots config => no slots block');
|
||||
});
|
||||
|
||||
test('a queue entry on the slot date covers it', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const day = weekdayOf(today);
|
||||
// A weekly slot has exactly two occurrences in a 14-day window from today.
|
||||
const ctx = runHook({
|
||||
slots: [{ day, time: '08:30' }],
|
||||
queue: [
|
||||
{ id: 'a', scheduled_date: today, scheduled_time: '08:30', status: 'scheduled' },
|
||||
{ id: 'b', scheduled_date: isoDaysAhead(7), scheduled_time: '08:30', status: 'scheduled' },
|
||||
],
|
||||
});
|
||||
assert.ok(ctx.includes(SLOTS_HEADER), 'a configured grid still reports');
|
||||
assert.ok(!ctx.includes(NEXT_OPEN), 'a fully covered horizon has no open slot to name');
|
||||
assert.ok(/are covered/.test(ctx), 'expected the all-covered confirmation');
|
||||
});
|
||||
|
||||
test('a scheduled long-form edition covers its claimed slot', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const day = weekdayOf(today);
|
||||
const ctx = runHook({
|
||||
slots: [{ day, time: '08:30' }],
|
||||
editions: [
|
||||
activeRow({ slot: `${today} 08:30` }),
|
||||
activeRow({ editionId: '06', slot: `${isoDaysAhead(7)} 08:30` }),
|
||||
],
|
||||
});
|
||||
assert.ok(!ctx.includes(NEXT_OPEN), 'register-claimed slots count as covered');
|
||||
});
|
||||
|
||||
test('an imminent empty slot (<=3 days) says how to fill it', () => {
|
||||
const target = isoDaysAhead(2);
|
||||
const ctx = runHook({ slots: [{ day: weekdayOf(target), time: '09:00' }] });
|
||||
assert.ok(/\/linkedin:newsletter/.test(ctx), 'expected the fill-it pointer for an imminent vacancy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-start — N13 leaves the existing nudges intact', () => {
|
||||
test('the trend-staleness (discovery) nudge still fires alongside the new blocks', () => {
|
||||
const ctx = runHook({
|
||||
slots: [{ day: weekdayOf(isoDaysAhead(3)), time: '12:00' }],
|
||||
editions: [activeRow()],
|
||||
trends: [
|
||||
{
|
||||
id: 't1',
|
||||
title: 'A trend',
|
||||
url: 'https://example.com/1',
|
||||
source: 'tavily',
|
||||
capturedAt: isoDaysAgo(10),
|
||||
topics: ['x'],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(ctx.includes('Trend signals are'), 'the discovery nudge must survive N13');
|
||||
assert.ok(ctx.includes(SLOTS_HEADER) && ctx.includes(WIP_HEADER), 'the new blocks render too');
|
||||
});
|
||||
});
|
||||
313
hooks/scripts/__tests__/slots.test.mjs
Normal file
313
hooks/scripts/__tests__/slots.test.mjs
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
SLOTS_SCHEMA_VERSION,
|
||||
normalizeSlots,
|
||||
readSlots,
|
||||
slotOccurrences,
|
||||
coverageByDate,
|
||||
vacantSlots,
|
||||
nextFreeSlot,
|
||||
activeEditions,
|
||||
defaultSlotsPath,
|
||||
defaultRegisterPath,
|
||||
} from '../slots.mjs';
|
||||
|
||||
// N13 (A1-10): the week's production grid — configured publishing slots, what
|
||||
// covers them (queue + editions register) and what is still in flight. The core
|
||||
// is pure (dates in as strings, no clock) so vacancy is testable without
|
||||
// freezing time; only the thin readers touch the data dir.
|
||||
|
||||
// Run `fn` with an isolated LINKEDIN_STUDIO_DATA root, seeded by `seed(dir)`.
|
||||
function withDataRoot(seed, fn) {
|
||||
const data = mkdtempSync(join(tmpdir(), 'lis-slots-'));
|
||||
const prev = process.env.LINKEDIN_STUDIO_DATA;
|
||||
process.env.LINKEDIN_STUDIO_DATA = data;
|
||||
try {
|
||||
if (seed) seed(data);
|
||||
return fn(data);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
|
||||
else process.env.LINKEDIN_STUDIO_DATA = prev;
|
||||
rmSync(data, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedSlots(data, config) {
|
||||
mkdirSync(join(data, 'profile'), { recursive: true });
|
||||
writeFileSync(join(data, 'profile', 'publishing-slots.json'), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function seedQueue(data, entries) {
|
||||
mkdirSync(join(data, 'drafts'), { recursive: true });
|
||||
writeFileSync(join(data, 'drafts', 'queue.json'), JSON.stringify({ version: 1, queue: entries }));
|
||||
}
|
||||
|
||||
function seedRegister(data, editions) {
|
||||
mkdirSync(join(data, 'editions'), { recursive: true });
|
||||
writeFileSync(join(data, 'editions', 'register.json'), JSON.stringify({ schemaVersion: 1, editions }));
|
||||
}
|
||||
|
||||
const row = (over = {}) => ({
|
||||
series: 'serien',
|
||||
editionId: '05',
|
||||
title: 'En utgave',
|
||||
path: '/series/serien',
|
||||
currentPhase: 'draft',
|
||||
nextAction: 'Step 4 — full draft',
|
||||
slot: null,
|
||||
startedAt: '2026-07-20T08:00:00.000Z',
|
||||
updatedAt: '2026-07-22T08:00:00.000Z',
|
||||
completedAt: null,
|
||||
status: 'active',
|
||||
...over,
|
||||
});
|
||||
|
||||
const queueEntry = (over = {}) => ({
|
||||
id: 'p1',
|
||||
scheduled_date: '2026-07-28',
|
||||
scheduled_time: '08:30',
|
||||
status: 'scheduled',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('slots — config normalization (N13/A1-10)', () => {
|
||||
test('accepts full weekday names and abbreviations, any case', () => {
|
||||
const { slots } = normalizeSlots({
|
||||
slots: [
|
||||
{ day: 'Tuesday', time: '08:30' },
|
||||
{ day: 'THU', time: '12:00', label: 'long-form' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
slots,
|
||||
[
|
||||
{ day: 'tue', time: '08:30', label: '' },
|
||||
{ day: 'thu', time: '12:00', label: 'long-form' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('drops entries with an unknown day or a malformed time', () => {
|
||||
const { slots } = normalizeSlots({
|
||||
slots: [
|
||||
{ day: 'funday', time: '08:30' },
|
||||
{ day: 'mon', time: '25:00' },
|
||||
{ day: 'mon', time: '8:30' },
|
||||
{ day: 'wed', time: '09:00' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(slots, [{ day: 'wed', time: '09:00', label: '' }]);
|
||||
});
|
||||
|
||||
test('garbage in never throws — it yields an empty grid', () => {
|
||||
for (const bad of [null, undefined, 42, 'nope', {}, { slots: 'x' }]) {
|
||||
assert.deepEqual(normalizeSlots(bad).slots, [], `expected [] for ${JSON.stringify(bad)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('carries the timezone through as a label, defaulting to empty', () => {
|
||||
assert.equal(normalizeSlots({ timezone: 'Europe/Oslo', slots: [] }).timezone, 'Europe/Oslo');
|
||||
assert.equal(normalizeSlots({ slots: [] }).timezone, '');
|
||||
});
|
||||
|
||||
test('exposes the schema version it reads', () => {
|
||||
assert.equal(SLOTS_SCHEMA_VERSION, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots — occurrence expansion (N13/A1-10)', () => {
|
||||
const grid = [
|
||||
{ day: 'tue', time: '08:30', label: '' },
|
||||
{ day: 'thu', time: '12:00', label: 'long-form' },
|
||||
];
|
||||
|
||||
test('expands the weekly grid across the horizon, chronologically', () => {
|
||||
// 2026-07-27 is a Monday.
|
||||
const occ = slotOccurrences(grid, '2026-07-27', 14);
|
||||
assert.deepEqual(
|
||||
occ.map((o) => `${o.date} ${o.time}`),
|
||||
['2026-07-28 08:30', '2026-07-30 12:00', '2026-08-04 08:30', '2026-08-06 12:00'],
|
||||
);
|
||||
assert.equal(occ[1].label, 'long-form');
|
||||
});
|
||||
|
||||
test('includes the start day itself — a slot today is still actionable', () => {
|
||||
const occ = slotOccurrences(grid, '2026-07-28', 1);
|
||||
assert.deepEqual(occ.map((o) => o.date), ['2026-07-28']);
|
||||
});
|
||||
|
||||
test('two slots on the same day come out in time order', () => {
|
||||
const occ = slotOccurrences(
|
||||
[{ day: 'tue', time: '16:00', label: '' }, { day: 'tue', time: '08:30', label: '' }],
|
||||
'2026-07-28',
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(occ.map((o) => o.time), ['08:30', '16:00']);
|
||||
});
|
||||
|
||||
test('no slots, or a zero horizon, yields nothing', () => {
|
||||
assert.deepEqual(slotOccurrences([], '2026-07-27', 14), []);
|
||||
assert.deepEqual(slotOccurrences(grid, '2026-07-27', 0), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots — coverage and vacancy (N13/A1-10)', () => {
|
||||
test('a scheduled queue entry covers its date', () => {
|
||||
const cover = coverageByDate([queueEntry()], []);
|
||||
assert.equal(cover.get('2026-07-28'), 1);
|
||||
});
|
||||
|
||||
test('a published entry still counts — the slot was used', () => {
|
||||
const cover = coverageByDate([queueEntry({ status: 'published' })], []);
|
||||
assert.equal(cover.get('2026-07-28'), 1);
|
||||
});
|
||||
|
||||
test('a cancelled entry does not cover — that slot fell vacant again', () => {
|
||||
const cover = coverageByDate([queueEntry({ status: 'cancelled' })], []);
|
||||
assert.equal(cover.get('2026-07-28'), undefined);
|
||||
});
|
||||
|
||||
test('an editions-register row covers the date of its slot, completed rows included', () => {
|
||||
const cover = coverageByDate([], [
|
||||
row({ slot: '2026-07-30 12:00' }),
|
||||
row({ editionId: '06', slot: '2026-08-06 12:00', status: 'complete', completedAt: '2026-08-01T00:00:00.000Z' }),
|
||||
]);
|
||||
assert.equal(cover.get('2026-07-30'), 1);
|
||||
assert.equal(cover.get('2026-08-06'), 1);
|
||||
});
|
||||
|
||||
test('rows without a claimed slot cover nothing', () => {
|
||||
assert.equal(coverageByDate([], [row({ slot: null })]).size, 0);
|
||||
});
|
||||
|
||||
test('vacancy is counted per date — two slots one day need two posts', () => {
|
||||
const occ = slotOccurrences(
|
||||
[{ day: 'tue', time: '08:30', label: '' }, { day: 'tue', time: '16:00', label: '' }],
|
||||
'2026-07-28',
|
||||
1,
|
||||
);
|
||||
const oneCovered = vacantSlots(occ, coverageByDate([queueEntry()], []));
|
||||
assert.deepEqual(oneCovered.map((o) => o.time), ['16:00'], 'one post covers only the first slot that day');
|
||||
|
||||
const bothCovered = vacantSlots(occ, coverageByDate([queueEntry(), queueEntry({ id: 'p2' })], []));
|
||||
assert.deepEqual(bothCovered, []);
|
||||
});
|
||||
|
||||
test('uncovered occurrences survive in chronological order', () => {
|
||||
const occ = slotOccurrences([{ day: 'tue', time: '08:30', label: '' }], '2026-07-27', 14);
|
||||
const vacant = vacantSlots(occ, coverageByDate([queueEntry()], []));
|
||||
assert.deepEqual(vacant.map((o) => o.date), ['2026-08-04']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots — data-dir readers (N13/A1-10)', () => {
|
||||
test('readSlots reads the per-user config under profile/', () => {
|
||||
withDataRoot(
|
||||
(d) => seedSlots(d, { schemaVersion: 1, timezone: 'Europe/Oslo', slots: [{ day: 'thu', time: '12:00' }] }),
|
||||
() => {
|
||||
const { slots, timezone } = readSlots();
|
||||
assert.equal(timezone, 'Europe/Oslo');
|
||||
assert.deepEqual(slots, [{ day: 'thu', time: '12:00', label: '' }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an absent config is silence, not an error — the un-configured user is never nagged', () => {
|
||||
withDataRoot(null, () => {
|
||||
assert.deepEqual(readSlots().slots, []);
|
||||
assert.equal(nextFreeSlot({ from: '2026-07-27', days: 14 }), null);
|
||||
});
|
||||
});
|
||||
|
||||
test('a malformed config degrades to an empty grid instead of crashing session start', () => {
|
||||
withDataRoot(
|
||||
(d) => {
|
||||
mkdirSync(join(d, 'profile'), { recursive: true });
|
||||
writeFileSync(join(d, 'profile', 'publishing-slots.json'), '{ not json');
|
||||
},
|
||||
() => assert.deepEqual(readSlots().slots, []),
|
||||
);
|
||||
});
|
||||
|
||||
test('the config and register paths resolve under the per-user data root', () => {
|
||||
withDataRoot(null, (d) => {
|
||||
assert.equal(defaultSlotsPath(), join(d, 'profile', 'publishing-slots.json'));
|
||||
assert.equal(defaultRegisterPath(), join(d, 'editions', 'register.json'));
|
||||
});
|
||||
});
|
||||
|
||||
test('reading vacancy never creates the queue file — a read-only check writes nothing', () => {
|
||||
withDataRoot(
|
||||
(d) => seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] }),
|
||||
(d) => {
|
||||
nextFreeSlot({ from: '2026-07-27', days: 14 });
|
||||
assert.equal(
|
||||
existsSync(join(d, 'drafts', 'queue.json')),
|
||||
false,
|
||||
'the vacancy read must not materialize queue.json',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots — nextFreeSlot end to end (N13/A1-10)', () => {
|
||||
test('returns the earliest uncovered slot, skipping what the queue and register cover', () => {
|
||||
withDataRoot(
|
||||
(d) => {
|
||||
seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }, { day: 'thu', time: '12:00', label: 'long-form' }] });
|
||||
seedQueue(d, [queueEntry()]); // covers Tue 2026-07-28
|
||||
seedRegister(d, [row({ slot: '2026-07-30 12:00' })]); // covers Thu 2026-07-30
|
||||
},
|
||||
() => {
|
||||
const next = nextFreeSlot({ from: '2026-07-27', days: 14 });
|
||||
assert.equal(next.date, '2026-08-04');
|
||||
assert.equal(next.time, '08:30');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('null when every slot in the horizon is covered', () => {
|
||||
withDataRoot(
|
||||
(d) => {
|
||||
seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] });
|
||||
seedQueue(d, [queueEntry()]);
|
||||
},
|
||||
() => assert.equal(nextFreeSlot({ from: '2026-07-27', days: 7 }), null),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slots — editions in flight (N13/A1-11 surfacing)', () => {
|
||||
test('lists active rows only, oldest series first, with whole days in flight', () => {
|
||||
withDataRoot(
|
||||
(d) =>
|
||||
seedRegister(d, [
|
||||
row({ series: 'b-serien', editionId: '02' }),
|
||||
row({ series: 'a-serien', editionId: '07', startedAt: '2026-07-24T00:00:00.000Z' }),
|
||||
row({ series: 'a-serien', editionId: '01', status: 'complete', completedAt: '2026-07-21T00:00:00.000Z' }),
|
||||
]),
|
||||
() => {
|
||||
const rows = activeEditions({ now: '2026-07-27T00:00:00.000Z' });
|
||||
assert.deepEqual(
|
||||
rows.map((r) => `${r.series}#${r.editionId}`),
|
||||
['a-serien#07', 'b-serien#02'],
|
||||
'complete rows drop out of the in-flight view',
|
||||
);
|
||||
assert.equal(rows[0].daysInFlight, 3);
|
||||
// Started 2026-07-20 08:00Z, now 2026-07-27 00:00Z = 6.67 days — floored,
|
||||
// like every other elapsed-day report in the hook layer.
|
||||
assert.equal(rows[1].daysInFlight, 6);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('an absent register is an empty view, never a crash', () => {
|
||||
withDataRoot(null, () => assert.deepEqual(activeEditions({ now: '2026-07-27T00:00:00.000Z' }), []));
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,15 @@ import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs';
|
|||
import { applyWeekRollover } from './week-rollover.mjs';
|
||||
import { getDataRoot } from './data-root.mjs';
|
||||
import { migrateData } from './migrate-data.mjs';
|
||||
import {
|
||||
activeEditions,
|
||||
readSlots,
|
||||
slotOccurrences,
|
||||
slotVacancies,
|
||||
daysUntil,
|
||||
formatSlot,
|
||||
defaultSlotsPath,
|
||||
} from './slots.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = join(__dirname, '..', '..');
|
||||
|
|
@ -536,6 +545,49 @@ if (latestBrief && latestBrief.summary) {
|
|||
context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`;
|
||||
}
|
||||
|
||||
// N13 (A1-10/A1-11): the week's production picture — long-form editions in
|
||||
// flight, and publishing slots nothing covers yet. Unconditional like the brain
|
||||
// nudge above (the fresh-install branch reassigns `context`, so an earlier
|
||||
// append would be clobbered). Zero-tsx: slots.mjs reads the register, the queue
|
||||
// and the grid as plain JSON, the same discipline as trendsNewestCapture.
|
||||
// Both blocks are silent when their data is absent — an operator who has
|
||||
// configured no slots and started no edition is never nagged.
|
||||
const SLOT_HORIZON_DAYS = 14;
|
||||
const SLOT_IMMINENT_DAYS = 3;
|
||||
try {
|
||||
const inFlight = activeEditions();
|
||||
if (inFlight.length > 0) {
|
||||
context += '\\n## Editions in Flight\\n';
|
||||
for (const e of inFlight) {
|
||||
const age = e.daysInFlight === null ? '' : ` | ${e.daysInFlight}d in flight`;
|
||||
context += `- ${e.series} #${e.editionId} — ${e.title || 'untitled'} | phase: ${e.currentPhase}`;
|
||||
context += ` | next: ${e.nextAction || 'unset'} | slot: ${e.slot || 'unclaimed'}${age}\\n`;
|
||||
}
|
||||
context += '- Resume an edition with /linkedin:newsletter (it reads edition-state.json, not this view).\\n';
|
||||
}
|
||||
|
||||
const { slots } = readSlots();
|
||||
if (slots.length > 0) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const planned = slotOccurrences(slots, today, SLOT_HORIZON_DAYS).length;
|
||||
const vacancies = slotVacancies({ from: today, days: SLOT_HORIZON_DAYS });
|
||||
context += '\\n## Publishing Slots\\n';
|
||||
if (vacancies.length > 0) {
|
||||
const until = daysUntil(today, vacancies[0].date);
|
||||
context += `- Next open slot: ${formatSlot(vacancies[0], today)}\\n`;
|
||||
context += `- Open in the next ${SLOT_HORIZON_DAYS} days: ${vacancies.length} of ${planned} slot(s)\\n`;
|
||||
if (until !== null && until <= SLOT_IMMINENT_DAYS) {
|
||||
context += '- Fill it: /linkedin:batch (short-form) or /linkedin:newsletter (long-form).\\n';
|
||||
}
|
||||
} else {
|
||||
context += `- All ${planned} slot(s) in the next ${SLOT_HORIZON_DAYS} days are covered.\\n`;
|
||||
}
|
||||
context += `- Grid: ${defaultSlotsPath()}\\n`;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: a broken grid or register must never block session start.
|
||||
}
|
||||
|
||||
// Read REMEMBER.md for user session context
|
||||
const rememberFile = join(getDataRoot(), 'REMEMBER.md');
|
||||
const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md');
|
||||
|
|
|
|||
211
hooks/scripts/slots.mjs
Normal file
211
hooks/scripts/slots.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env node
|
||||
// Publishing slots + work-in-progress view (N13 — A1-10).
|
||||
//
|
||||
// The week's production grid in one place: the slots the operator publishes in,
|
||||
// what already covers them (the short-form queue + the editions register), and
|
||||
// which long-form editions are still in flight. Session start reads it so the
|
||||
// week is visible without opening a file; the commands read the SAME functions
|
||||
// through `node --input-type=module -e` (the queue-manager.mjs pattern), so the
|
||||
// vacancy the hook reports and the slot `/linkedin:newsletter` defaults to can
|
||||
// never disagree.
|
||||
//
|
||||
// Zero-dep on purpose: this runs inside a SessionStart hook, which must not
|
||||
// spawn tsx or touch node_modules (same discipline as session-start.mjs's direct
|
||||
// trend-store read).
|
||||
//
|
||||
// DOMAIN-NEUTRAL: the plugin ships no publishing times. The grid lives in the
|
||||
// per-user data dir (`profile/publishing-slots.json`); an absent config means
|
||||
// silence, never a default schedule imposed on the user.
|
||||
//
|
||||
// The core is pure — dates in as `YYYY-MM-DD` strings, no clock — so vacancy is
|
||||
// testable without freezing time. Only the thin readers below touch the disk.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getDataRoot } from './data-root.mjs';
|
||||
|
||||
export const SLOTS_SCHEMA_VERSION = 1;
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
|
||||
// Canonical day keys (ISO-ish 3-letter, lowercase) → JS getUTCDay index.
|
||||
const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
||||
const DAY_LABEL = { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' };
|
||||
|
||||
// A queue entry counts as covering its date when it is still going out or has
|
||||
// already gone out. A cancelled entry does NOT — that slot fell vacant again.
|
||||
const COVERING_QUEUE_STATUS = new Set(['scheduled', 'published']);
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/** `${DATA}/profile/publishing-slots.json` — user config, survives reinstalls (M0). */
|
||||
export function defaultSlotsPath() {
|
||||
return join(getDataRoot('profile'), 'publishing-slots.json');
|
||||
}
|
||||
|
||||
/** `${DATA}/editions/register.json` — twin of scripts/editions/src/register.ts:defaultRegisterPath. */
|
||||
export function defaultRegisterPath() {
|
||||
return join(getDataRoot('editions'), 'register.json');
|
||||
}
|
||||
|
||||
/** `${DATA}/drafts/queue.json` — read directly (never via queue-manager's ensureQueue: a read-only check must not create files). */
|
||||
function queuePath() {
|
||||
return join(getDataRoot('drafts'), 'queue.json');
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw config into the grid the rest of the module speaks.
|
||||
* Forgiving by design: an unusable row is dropped, never thrown — a typo in a
|
||||
* config file must not take down session start.
|
||||
*/
|
||||
export function normalizeSlots(raw) {
|
||||
const timezone = typeof raw?.timezone === 'string' ? raw.timezone : '';
|
||||
const rows = Array.isArray(raw?.slots) ? raw.slots : [];
|
||||
const slots = [];
|
||||
for (const r of rows) {
|
||||
const day = String(r?.day ?? '').trim().toLowerCase().slice(0, 3);
|
||||
const time = String(r?.time ?? '').trim();
|
||||
if (!(day in DAY_INDEX) || !TIME_RE.test(time)) continue;
|
||||
slots.push({ day, time, label: typeof r?.label === 'string' ? r.label : '' });
|
||||
}
|
||||
return { timezone, slots };
|
||||
}
|
||||
|
||||
/** Read the per-user grid. Absent/malformed ⇒ empty grid (silence, not a nag). */
|
||||
export function readSlots(path = defaultSlotsPath()) {
|
||||
return normalizeSlots(readJson(path));
|
||||
}
|
||||
|
||||
function addDays(dateStr, n) {
|
||||
// Noon UTC anchor — the codebase idiom (state-updater.mjs) that keeps date
|
||||
// arithmetic clear of DST edges.
|
||||
const d = new Date(`${dateStr}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function weekdayKey(dateStr) {
|
||||
const idx = new Date(`${dateStr}T12:00:00Z`).getUTCDay();
|
||||
return Object.keys(DAY_INDEX).find((k) => DAY_INDEX[k] === idx) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the weekly grid into concrete occurrences across `days` calendar days
|
||||
* starting at (and including) `from` — a slot today is still actionable.
|
||||
*/
|
||||
export function slotOccurrences(slots, from, days) {
|
||||
const out = [];
|
||||
for (let i = 0; i < days; i += 1) {
|
||||
const date = addDays(from, i);
|
||||
const key = weekdayKey(date);
|
||||
for (const s of slots) {
|
||||
if (s.day === key) out.push({ date, day: s.day, dayLabel: DAY_LABEL[s.day], time: s.time, label: s.label });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many published/scheduled things land on each date — counted, not merely
|
||||
* flagged, so a day carrying two slots needs two posts to be full.
|
||||
*/
|
||||
export function coverageByDate(queueEntries, editionRows) {
|
||||
const counts = new Map();
|
||||
const bump = (date) => {
|
||||
if (!date) return;
|
||||
counts.set(date, (counts.get(date) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const e of queueEntries ?? []) {
|
||||
if (COVERING_QUEUE_STATUS.has(e?.status)) bump(e?.scheduled_date);
|
||||
}
|
||||
// Register rows carry `slot` as "YYYY-MM-DD HH:MM". Completed rows count too:
|
||||
// an edition that reached Step 10 IS the thing occupying that slot.
|
||||
for (const r of editionRows ?? []) {
|
||||
if (typeof r?.slot === 'string' && r.slot.length >= 10) bump(r.slot.slice(0, 10));
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** The occurrences nothing covers, chronological. Per date, the earliest slots are considered filled first. */
|
||||
export function vacantSlots(occurrences, coverage) {
|
||||
const remaining = new Map(coverage);
|
||||
const vacant = [];
|
||||
for (const occ of occurrences) {
|
||||
const left = remaining.get(occ.date) ?? 0;
|
||||
if (left > 0) remaining.set(occ.date, left - 1);
|
||||
else vacant.push(occ);
|
||||
}
|
||||
return vacant;
|
||||
}
|
||||
|
||||
/** All register rows, whatever their status (coverage cares about completed ones too). */
|
||||
export function readRegisterRows(path = defaultRegisterPath()) {
|
||||
const parsed = readJson(path);
|
||||
return Array.isArray(parsed?.editions) ? parsed.editions : [];
|
||||
}
|
||||
|
||||
function readQueueEntries() {
|
||||
const parsed = readJson(queuePath());
|
||||
return Array.isArray(parsed?.queue) ? parsed.queue : [];
|
||||
}
|
||||
|
||||
function todayISO() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Every uncovered slot in the horizon — the vacancy warning's raw material. */
|
||||
export function slotVacancies({ from = todayISO(), days = 14 } = {}) {
|
||||
const { slots } = readSlots();
|
||||
if (slots.length === 0) return [];
|
||||
const occurrences = slotOccurrences(slots, from, days);
|
||||
return vacantSlots(occurrences, coverageByDate(readQueueEntries(), readRegisterRows()));
|
||||
}
|
||||
|
||||
/** The slot `/linkedin:newsletter` Step 10 defaults to; null when nothing is configured or everything is covered. */
|
||||
export function nextFreeSlot(options = {}) {
|
||||
return slotVacancies(options)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editions in production right now, with whole days in flight (the `daysSince`
|
||||
* idiom the hook already reports elapsed time with). Deterministic order —
|
||||
* series, then edition id — mirroring listEditions in scripts/editions.
|
||||
*/
|
||||
export function activeEditions({ now = new Date().toISOString() } = {}) {
|
||||
const nowMs = Date.parse(now);
|
||||
return readRegisterRows()
|
||||
.filter((r) => r?.status === 'active')
|
||||
.sort((a, b) => String(a.series).localeCompare(String(b.series)) || String(a.editionId).localeCompare(String(b.editionId)))
|
||||
.map((r) => {
|
||||
const started = Date.parse(r?.startedAt ?? '');
|
||||
const daysInFlight =
|
||||
Number.isNaN(started) || Number.isNaN(nowMs) ? null : Math.floor((nowMs - started) / MS_PER_DAY);
|
||||
return { ...r, daysInFlight };
|
||||
});
|
||||
}
|
||||
|
||||
/** "today" / "tomorrow" / "in N days" — how far off a slot is, in words. */
|
||||
export function daysUntil(from, date) {
|
||||
const a = Date.parse(`${from}T12:00:00Z`);
|
||||
const b = Date.parse(`${date}T12:00:00Z`);
|
||||
if (Number.isNaN(a) || Number.isNaN(b)) return null;
|
||||
return Math.round((b - a) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** One slot rendered for a human: "Thu 2026-07-30 12:00 (in 3 days)". */
|
||||
export function formatSlot(slot, from = todayISO()) {
|
||||
const n = daysUntil(from, slot.date);
|
||||
const when = n === null ? '' : n === 0 ? ' (today)' : n === 1 ? ' (tomorrow)' : ` (in ${n} days)`;
|
||||
const label = slot.label ? ` — ${slot.label}` : '';
|
||||
return `${slot.dayLabel} ${slot.date} ${slot.time}${when}${label}`;
|
||||
}
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
# Post Scheduling Strategy
|
||||
|
||||
Reference for calculating optimal posting schedule based on weekly goal and content mix.
|
||||
Reference for calculating a posting schedule from weekly goal and content mix.
|
||||
|
||||
## Optimal Posting Slots
|
||||
## Epistemic status (D-5)
|
||||
|
||||
Based on `weekly_goal` from state file:
|
||||
**Confidence: low / directional** for every day/time in this file — practitioner
|
||||
heuristic, no primary source. Same standard as
|
||||
`references/algorithm-signals-reference.md`: treat these as a hypothesis to test per
|
||||
account, not as a finding about your audience. LinkedIn publishes no optimal-time data,
|
||||
and the numbers below come from general B2B practitioner lore, not from measurement of
|
||||
*your* followers.
|
||||
|
||||
The authoritative grid is therefore the **operator's own**, in
|
||||
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json`
|
||||
(schema + opt-in: `config/publishing-slots.template.json`). That file is what
|
||||
`/linkedin:calendar`, the session-start vacancy warning and `/linkedin:newsletter`
|
||||
Step 10 actually read. The tables here are the **starting point** it is seeded from,
|
||||
to be replaced with what `/linkedin:report` and `/linkedin:analyze` show for the
|
||||
account — the plugin hardcodes no publishing times.
|
||||
|
||||
## Directional Posting Slots
|
||||
|
||||
Based on `weekly_goal` from state file (times: low / directional — test per account):
|
||||
|
||||
### 2x/week
|
||||
| Slot | Day | Time (CET) | Rationale |
|
||||
|
|
@ -38,9 +55,14 @@ Based on `weekly_goal` from state file:
|
|||
|
||||
## Scheduling Algorithm
|
||||
|
||||
When the operator has a configured grid, `hooks/scripts/slots.mjs` answers "what is the
|
||||
next free slot" deterministically (`nextFreeSlot`) — coverage counts both queue entries
|
||||
and editions-register rows, so long-form and short-form cannot be scheduled on top of
|
||||
each other. The steps below are the fallback for an unconfigured grid.
|
||||
|
||||
When assigning dates to batch-created posts:
|
||||
|
||||
1. **Start from next available optimal slot** after today
|
||||
1. **Start from next available slot** after today
|
||||
2. **Skip slots that already have queued posts** (check queue.json)
|
||||
3. **If all slots this week are taken**, spill into next week
|
||||
4. **Assign in slot order** (earliest available first)
|
||||
|
|
@ -77,6 +99,11 @@ Ensure coverage across expertise areas:
|
|||
|
||||
## Time Zone Notes
|
||||
|
||||
Also low / directional, and written from one European vantage point — read the peaks
|
||||
below as an illustration of the *shape* of the question, not as your audience's hours.
|
||||
The operator's grid records its own IANA zone; the plugin schedules in local wall-clock
|
||||
time and converts nothing.
|
||||
|
||||
- All times are CET (Central European Time)
|
||||
- Norwegian audience peaks: 7:30-9:00 and 11:30-13:00
|
||||
- For international audiences, 08:30 CET catches both EU morning and US east coast pre-work
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ and mirrors the edition's row into the register.
|
|||
# at EVERY phase transition (after currentPhase has been written)
|
||||
node --import tsx src/cli.ts register-upsert \
|
||||
--edition-state "<serie>/linkedin/edition-state.json" \
|
||||
--next "Step 3a — spine prose" [--slot "2026-07-28 07:45"] [--path <series-root>]
|
||||
--next "Step 3a — spine prose" [--slot "2026-07-28 08:30"] [--path <series-root>]
|
||||
|
||||
# the week's work-in-progress, without opening a single series folder
|
||||
node --import tsx src/cli.ts register-list [--all] [--json]
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ describe("register — upsert", () => {
|
|||
test("omitted nextAction/slot/title are PRESERVED, not blanked", () => {
|
||||
const withSlot = upsertEdition(
|
||||
seed(),
|
||||
{ series: "seres", editionId: "05", path: "/p", currentPhase: "draft", slot: "2026-07-28 07:45" },
|
||||
{ series: "seres", editionId: "05", path: "/p", currentPhase: "draft", slot: "2026-07-28 08:30" },
|
||||
T1,
|
||||
).register;
|
||||
const { row } = upsertEdition(
|
||||
|
|
@ -123,7 +123,7 @@ describe("register — upsert", () => {
|
|||
{ series: "seres", editionId: "05", path: "/p", currentPhase: "consistency-quality" },
|
||||
T2,
|
||||
);
|
||||
assert.equal(row.slot, "2026-07-28 07:45");
|
||||
assert.equal(row.slot, "2026-07-28 08:30");
|
||||
assert.equal(row.nextAction, "Step 2.5 — skeleton");
|
||||
assert.equal(row.title, "Da pipelinen brøt sammen");
|
||||
});
|
||||
|
|
@ -137,12 +137,12 @@ describe("register — upsert", () => {
|
|||
path: "/p",
|
||||
currentPhase: "draft",
|
||||
nextAction: "Step 4 — consistency",
|
||||
slot: "2026-07-28 07:45",
|
||||
slot: "2026-07-28 08:30",
|
||||
},
|
||||
T1,
|
||||
);
|
||||
assert.equal(row.nextAction, "Step 4 — consistency");
|
||||
assert.equal(row.slot, "2026-07-28 07:45");
|
||||
assert.equal(row.slot, "2026-07-28 08:30");
|
||||
});
|
||||
|
||||
test("two editions of the same series are two rows", () => {
|
||||
|
|
|
|||
|
|
@ -2057,6 +2057,143 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16t: Publishing Slots + WIP Surfacing (N13 / A1-10) ---
|
||||
echo "--- Publishing Slots (N13) ---"
|
||||
|
||||
# N13 answers the question a queue cannot: is the WEEK covered? The grid of publishing
|
||||
# slots is operator config, coverage is queue + editions register, and the surfaces that
|
||||
# report it (session start, router, calendar) plus the one that consumes it (newsletter
|
||||
# Step 10 slot default) must all read the SAME module - two implementations of "next free
|
||||
# slot" would disagree within a week, and the operator would learn to trust neither.
|
||||
# (A1-10 wiring) a WIP surface loads slots.mjs and asks it for BOTH vacancy and
|
||||
# in-flight editions - a compound predicate, so a non-vacuity
|
||||
# self-test guards it (mirrors Sections 16p/16q/16r/16s).
|
||||
# (A1-10 consume) Step 10 defaults to the next free slot AND claims it on the row.
|
||||
# (A1-10 placement) the grid + register resolve under the per-user data dir (M0).
|
||||
# (D-5) the shipped scheduling times are marked low/directional and defer
|
||||
# to the operator's own grid.
|
||||
# (de-niche) the plugin ships NO publishing times in the slot code path and no
|
||||
# operator grid - the times are user config, always.
|
||||
SLOTS_SRC="hooks/scripts/slots.mjs"
|
||||
SS_SRC="hooks/scripts/session-start.mjs"
|
||||
SLOTS_TPL="config/publishing-slots.template.json"
|
||||
NL_N13="commands/newsletter.md"
|
||||
SCHED_REF="references/scheduling-strategy.md"
|
||||
|
||||
slots_surface_wired() { # $1 = text; wired iff it LOADS the shared module and asks for BOTH vacancy and in-flight
|
||||
echo "$1" | grep -qF "slots.mjs" \
|
||||
&& echo "$1" | grep -qE "slotVacancies|nextFreeSlot" \
|
||||
&& echo "$1" | grep -qF "activeEditions"
|
||||
}
|
||||
|
||||
SL_SELFTEST_OK=1
|
||||
if ! slots_surface_wired "import { slotVacancies, activeEditions } from hooks/scripts/slots.mjs and print both"; then
|
||||
SL_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired slot-surface probe was not detected"
|
||||
fi
|
||||
while IFS= read -r probe; do
|
||||
[ -z "$probe" ] && continue
|
||||
if slots_surface_wired "$probe"; then
|
||||
SL_SELFTEST_OK=0; echo " false-positive FAIL: under-wired slot-surface probe accepted -> $probe"
|
||||
fi
|
||||
done <<'NEGATIVE16T'
|
||||
import activeEditions from slots.mjs and list what is in production
|
||||
call slotVacancies and activeEditions from somewhere, no module named
|
||||
import slotVacancies from slots.mjs and report open slots only
|
||||
NEGATIVE16T
|
||||
if [ "$SL_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "slot-surface self-test: predicate needs slots.mjs + vacancy + activeEditions (1 accepted, 3 under-wired rejected)"
|
||||
else
|
||||
fail "slot-surface self-test failed - the N13 wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
# (A1-10 wiring) the two WIP surfaces report capacity, not just a queue
|
||||
if slots_surface_wired "$(cat commands/linkedin.md)"; then
|
||||
pass "router surfaces work-in-progress + open slots from the shared module (A1-10)"
|
||||
else
|
||||
fail "commands/linkedin.md does not surface in-flight editions and open slots - the week stays invisible (A1-10)"
|
||||
fi
|
||||
|
||||
if slots_surface_wired "$(cat commands/calendar.md)"; then
|
||||
pass "calendar surfaces the slot grid + editions in flight from the shared module (A1-10)"
|
||||
else
|
||||
fail "commands/calendar.md does not read slots.mjs for vacancy + in-flight editions (A1-10)"
|
||||
fi
|
||||
|
||||
# (A1-10 opt-in) the grid is offered, never written for the user
|
||||
if grep -qF "publishing-slots.template.json" commands/calendar.md; then
|
||||
pass "calendar documents the opt-in grid copy - the plugin imposes no schedule (A1-10/de-niche)"
|
||||
else
|
||||
fail "commands/calendar.md never points at the slot-grid template - the feature is undiscoverable (A1-10)"
|
||||
fi
|
||||
|
||||
# (A1-10 session start) both blocks render at session start, from the same module
|
||||
if grep -qF "from './slots.mjs'" "$SS_SRC" \
|
||||
&& grep -qF "## Editions in Flight" "$SS_SRC" \
|
||||
&& grep -qF "## Publishing Slots" "$SS_SRC"; then
|
||||
pass "session start renders in-flight editions + publishing slots from slots.mjs (A1-10)"
|
||||
else
|
||||
fail "$SS_SRC does not render the week picture from slots.mjs - the operator must open files again (A1-10)"
|
||||
fi
|
||||
|
||||
# (A1-10 coverage) vacancy counts BOTH short-form queue and long-form register
|
||||
if grep -qF "export function coverageByDate" "$SLOTS_SRC" && grep -qF "readRegisterRows" "$SLOTS_SRC"; then
|
||||
pass "slot coverage counts the queue AND the editions register - long-form cannot be double-booked (A1-10)"
|
||||
else
|
||||
fail "$SLOTS_SRC does not derive coverage from both the queue and the editions register (A1-10)"
|
||||
fi
|
||||
|
||||
# (A1-10 placement) grid + register resolve under the per-user data dir (M0 convention)
|
||||
if grep -qF "getDataRoot('profile')" "$SLOTS_SRC" && grep -qF "getDataRoot('editions')" "$SLOTS_SRC"; then
|
||||
pass "slot grid + register resolve under the per-user data dir - they survive reinstalls (M0/A1-10)"
|
||||
else
|
||||
fail "$SLOTS_SRC does not resolve the grid and register under the data dir (M0/A1-10)"
|
||||
fi
|
||||
|
||||
# (A1-10 consume) Step 10 defaults to the next free slot instead of interviewing
|
||||
STEP10_N13=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N13")
|
||||
if echo "$STEP10_N13" | grep -qF "nextFreeSlot"; then
|
||||
pass "newsletter Step 10 defaults to the next free slot - scheduling is one confirmation (A1-10)"
|
||||
else
|
||||
fail "newsletter Step 10 still asks for the slot from scratch - the grid buys nothing (A1-10)"
|
||||
fi
|
||||
|
||||
if echo "$STEP10_N13" | grep -qF -- "--slot"; then
|
||||
pass "newsletter Step 10 claims the slot on the register row - the next session stops offering it (A1-10)"
|
||||
else
|
||||
fail "newsletter Step 10 never writes --slot - a scheduled edition still reads as an open slot (A1-10)"
|
||||
fi
|
||||
|
||||
# (D-5) the shipped times are marked as what they are, and defer to the operator's grid
|
||||
if grep -qF "low / directional" "$SCHED_REF"; then
|
||||
pass "scheduling reference carries the low/directional epistemic marking (D-5)"
|
||||
else
|
||||
fail "$SCHED_REF presents practitioner lore as fact - mark it low / directional (D-5)"
|
||||
fi
|
||||
|
||||
if grep -qF "publishing-slots.json" "$SCHED_REF"; then
|
||||
pass "scheduling reference defers to the operator's own grid (D-5)"
|
||||
else
|
||||
fail "$SCHED_REF does not point at the operator grid - the tables read as the authority (D-5)"
|
||||
fi
|
||||
|
||||
# (de-niche) the template exists and states that nothing is written for the user
|
||||
if [ -f "$SLOTS_TPL" ] && grep -qF '"schemaVersion": 1' "$SLOTS_TPL" && grep -qF "OPT-IN" "$SLOTS_TPL"; then
|
||||
pass "slot-grid template ships with a schema and an explicit opt-in (A1-10/de-niche)"
|
||||
else
|
||||
fail "$SLOTS_TPL missing, unversioned, or does not state the opt-in (A1-10/de-niche)"
|
||||
fi
|
||||
|
||||
# (de-niche) no publishing times hardcoded in the slot code path, no operator grid committed
|
||||
SLOT_TIME_LITERALS=$(grep -cE "['\"][0-2][0-9]:[0-5][0-9]['\"]" "$SLOTS_SRC" "$SS_SRC" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
|
||||
COMMITTED_GRIDS=$(find . -name "publishing-slots.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$SLOT_TIME_LITERALS" -eq 0 ] && [ "$COMMITTED_GRIDS" -eq 0 ]; then
|
||||
pass "no publishing times hardcoded in the slot path and no operator grid committed (de-niche)"
|
||||
else
|
||||
fail "hardcoded slot times in the code path ($SLOT_TIME_LITERALS) or a committed operator grid ($COMMITTED_GRIDS) - times are user config (de-niche)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -2103,12 +2240,17 @@ echo ""
|
|||
# self-test + protocol-block compound grep + mirror-invariant grep + 16-phase coverage sweep +
|
||||
# Step-10 register-complete grep + register.ts upsertEdition grep + register.ts completeEdition/
|
||||
# listEditions grep + register.ts data-dir placement grep + editionState.ts appendPhase grep +
|
||||
# template phaseLog-doc grep + template phaseLog-slot grep) = 166.
|
||||
# template phaseLog-doc grep + template phaseLog-slot grep) = 166; +13 for N13's thirteen
|
||||
# UNCONDITIONAL Section-16t checks (slot-surface self-test + router wiring grep + calendar
|
||||
# wiring grep + calendar opt-in grep + session-start two-block compound grep + coverage
|
||||
# queue+register grep + data-dir placement grep + Step-10 nextFreeSlot grep + Step-10 --slot
|
||||
# grep + scheduling-ref directional grep + scheduling-ref operator-grid grep + slot-template
|
||||
# schema/opt-in grep + de-niche no-hardcoded-times/no-committed-grid check) = 179.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=166
|
||||
ASSERT_BASELINE_FLOOR=179
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue