n-seo 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,48 @@ uses [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.6.0] - 2026-09-15
12
+
13
+ ### Added
14
+ - **Profiles carry principles.** Building the first real profile showed the
15
+ format was too thin. Measured against ten sites, one practitioner's method
16
+ was barely distinguishable from the engine's in *thresholds* — rows sort by
17
+ impressions before the cap, so the striking-distance floor never binds where
18
+ there is data, and lowering it would only have deleted the one marginal
19
+ site's two cards — and almost entirely distinguishable in *judgement*.
20
+ `principles` carries that: a title, a body, and a kind. `hard` is a
21
+ constraint an agent must not cross; `guide` is judgement it should apply.
22
+ They render at the top of the action queue and on Settings, and `n-seo init`
23
+ writes them into the instance's `CLAUDE.md`. A principle the agent never
24
+ reads is a note to yourself, not an operating rule.
25
+ - **`rules.priorities` — custom rules, declaratively.** A threshold says what
26
+ counts as a finding; a priority says what you care about. Match on `host`,
27
+ `pathMatches`, `tag` or `kind`, then `multiply` the impact that orders the
28
+ queue, or `drop` the card. Several compound in the order written.
29
+ - Two constraints the design will not bend on. **A priority may reorder or
30
+ hide, never invent** — it cannot create a card, so it cannot manufacture
31
+ evidence. And **every card it touches says so**, carrying the reason in its
32
+ spec and a *reweighted* chip; `why` is required and a priority without one
33
+ is ignored. Ordering you cannot see the reason for is ordering you cannot
34
+ argue with.
35
+ - A priority with no conditions matches nothing, a bad regular expression
36
+ matches nothing rather than throwing, and a nonsense multiplier is ignored
37
+ rather than corrupting the sort.
38
+
39
+ ### Decided
40
+ - **Profiles still may not ship executable code.** The obvious version of
41
+ custom rules is letting a profile carry a function. Installing someone's
42
+ method would then mean running their program on the machine holding your
43
+ Search Console credentials and your service-account key — a large amount of
44
+ trust for a list of thresholds. Matching and weighting turned out to cover
45
+ what people actually wanted to express. A genuinely new *kind* of card, one
46
+ reading data no existing rule reads, remains a fork and remains the open
47
+ question.
48
+
49
+ ### Fixed
50
+ - `.chip` ellipsizes at 34ch and shrinks as a flex item, which rendered the
51
+ word "hard" as "h…" on every principle.
52
+
11
53
  ## [0.5.0] - 2026-09-14
12
54
 
13
55
  The operating model was ours, hardcoded. Now it is a file you can install.
@@ -444,7 +486,8 @@ First public release.
444
486
  `n-seo upgrade` gate — without changing the reported test counts, and an
445
487
  in-place install ran those assertions against the owner's live data.
446
488
 
447
- [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...HEAD
489
+ [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.6.0...HEAD
490
+ [0.6.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...v0.6.0
448
491
  [0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
449
492
  [0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
450
493
  [0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
package/bin/n-seo.mjs CHANGED
@@ -144,6 +144,41 @@ function missingDeps(cmd, what) {
144
144
  return 1;
145
145
  }
146
146
 
147
+ /** A profile's principles, as markdown for the instance CLAUDE.md.
148
+ *
149
+ * The whole point of writing a method down is that the agent follows it too.
150
+ * A principle that lives only in the dashboard is a principle the thing
151
+ * doing the work never reads. */
152
+ function profilePrinciples(dir) {
153
+ const cfgPath = path.join(dir, "n-seo.config.json");
154
+ let spec;
155
+ try {
156
+ spec = JSON.parse(fs.readFileSync(cfgPath, "utf8")).profile;
157
+ } catch { return ""; }
158
+ if (!spec) return "";
159
+
160
+ const candidates = [
161
+ path.join(ROOT, "profiles", spec),
162
+ path.isAbsolute(spec) ? spec : path.resolve(dir, spec),
163
+ path.join(dir, "node_modules", spec),
164
+ path.join(ROOT, "node_modules", spec),
165
+ ];
166
+ const found = candidates.find((c) => fs.existsSync(path.join(c, "n-seo.profile.json")));
167
+ if (!found) return "";
168
+
169
+ let profile;
170
+ try {
171
+ profile = JSON.parse(fs.readFileSync(path.join(found, "n-seo.profile.json"), "utf8"));
172
+ } catch { return ""; }
173
+ const list = (profile.principles ?? []).filter((p) => p?.title && p?.body);
174
+ if (!list.length) return "";
175
+
176
+ const lines = list.map((p) => `- **${p.title}**${p.kind === "hard" ? " (hard rule)" : ""}\n ${p.body}`);
177
+ return `\n## From the ${profile.name} profile\n\n` +
178
+ `These came with the profile this instance runs. Hard rules are constraints, not advice.\n\n` +
179
+ lines.join("\n") + "\n";
180
+ }
181
+
147
182
  /* ---------- init ---------- */
148
183
 
149
184
  /** Files that mean "this directory is an application", not a place to keep
@@ -304,7 +339,7 @@ Skills in \`.claude/skills/\` cover the routine work — start with
304
339
  /actions until a human accepts them.
305
340
  - **Site changes ship as branches and pull requests** in the site's own repo,
306
341
  never committed straight to its main branch.
307
- `);
342
+ ${profilePrinciples(dir)}`);
308
343
 
309
344
  const envExample = path.join(ROOT, ".env.example");
310
345
  put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
package/docs/PRD.md CHANGED
@@ -157,10 +157,19 @@ published once and installed many times. Full reference: `docs/PROFILES.md`.
157
157
  - Acceptance: `default`, `patient` and `aggressive` ship with the engine, and
158
158
  `default` restates the engine defaults exactly — asserted, so the two cannot
159
159
  drift.
160
+ - Acceptance: `rules.priorities` reweights or drops queue cards by host,
161
+ path, tag or kind; a matched card carries the reason in its spec and a
162
+ *reweighted* chip; a priority with no `why` or no conditions is ignored; a
163
+ bad regex or a nonsense multiplier is ignored rather than throwing or
164
+ corrupting the sort.
165
+ - Acceptance: a priority can never create a card. Everything in the queue
166
+ still came from data.
160
167
  - Decided: a profile is data only and may not ship executable code. Installing
161
168
  a method should not mean running its author's code on the machine holding
162
- your Search Console credentials. Custom *rules* — new kinds of card rather
163
- than new numbers — remain a fork, and are the open question.
169
+ your Search Console credentials. `priorities` covers reordering and hiding
170
+ declaratively, which is what practitioners actually asked to express. A
171
+ genuinely new *kind* of card — one reading data no existing rule reads —
172
+ remains a fork, and is the open question.
164
173
 
165
174
  ## Feature: Rule thresholds in config [shipped]
166
175
 
package/docs/PROFILES.md CHANGED
@@ -75,10 +75,40 @@ this instance overrides 2 value(s):
75
75
  // Module defaults. The instance can still switch any of them.
76
76
  "modules": {
77
77
  "indexNow": { "enabled": true }
78
- }
78
+ },
79
+
80
+ // The parts of a method that are not a number.
81
+ "principles": [
82
+ {
83
+ "kind": "hard",
84
+ "title": "Never write to a CMS without per-item approval",
85
+ "body": "Show the exact before and after, get a yes on each one, then write."
86
+ },
87
+ { "title": "Judge by the path to a signup, not by clicks", "body": "..." }
88
+ ]
79
89
  }
80
90
  ```
81
91
 
92
+ ### Principles
93
+
94
+ The most useful thing we learned building the first real profile: a
95
+ practitioner's method is barely distinguishable from ours in *thresholds* and
96
+ almost entirely distinguishable in *judgement*. The striking-distance floor
97
+ turned out never to bind once rows are sorted by impressions. What actually
98
+ differed was what to optimise for, what needs a human's approval, and what to
99
+ never automate.
100
+
101
+ None of that is a number, so `principles` carries it. Each has a `title`, a
102
+ `body`, and a `kind`:
103
+
104
+ - **`hard`** — a constraint. An agent must not cross it.
105
+ - **`guide`** — judgement it should apply.
106
+
107
+ They appear at the top of the action queue, on the Settings page, and — this
108
+ is the part that matters — `n-seo init` writes them into the instance's
109
+ `CLAUDE.md`. A principle the agent never reads is not an operating rule, it is
110
+ a note to yourself.
111
+
82
112
  Set only what you disagree with. Anything absent is inherited, so a profile
83
113
  that changes one threshold is three lines long.
84
114
 
@@ -115,6 +145,61 @@ Then `npm i n-seo-profile-acme` in the instance and name it in the config.
115
145
  Versioning the package versions the method, which is the point: "we moved you
116
146
  to Acme Search 2.0" is a sentence a client can check.
117
147
 
148
+ ## Priorities — the declarative half of custom rules
149
+
150
+ A threshold says *what counts as a finding*. A priority says *what you care
151
+ about*. They are different questions, and the second one is where a method
152
+ usually lives.
153
+
154
+ ```jsonc
155
+ "rules": {
156
+ "priorities": [
157
+ {
158
+ "why": "leads somewhere: a signup, a tool, an account",
159
+ "when": { "pathMatches": "^/(tools|pricing|signup)" },
160
+ "multiply": 1.5
161
+ },
162
+ {
163
+ "why": "we have decided not to work on these",
164
+ "when": { "pathMatches": "^/legal/" },
165
+ "drop": true
166
+ }
167
+ ]
168
+ }
169
+ ```
170
+
171
+ `when` matches on `host`, `pathMatches` (a regular expression against the
172
+ page's path), `tag`, and `kind`. A priority with no conditions matches
173
+ nothing — applying to the whole queue is never what anyone means, and is a
174
+ miserable way to learn the shape of the config.
175
+
176
+ `multiply` scales the impact that orders the queue. `drop` removes the card.
177
+ Several priorities compound, in the order they are written.
178
+
179
+ ### Two rules the design will not bend on
180
+
181
+ **A priority may reorder or hide, never invent.** It cannot create a card, so
182
+ it cannot manufacture evidence. Everything in the queue still came from data.
183
+
184
+ **Every card it touches says so.** The impact number decides the order, so a
185
+ card whose number was adjusted carries the reason on its face and a
186
+ *reweighted* chip. `why` is required; a priority without one is ignored.
187
+ Ordering you cannot see the reason for is ordering you cannot argue with, and
188
+ the whole product rests on showing its working.
189
+
190
+ ### Why this is not a plugin API
191
+
192
+ The obvious version of "custom rules" is letting a profile ship code. We are
193
+ not doing that, and the reason is not squeamishness about JavaScript:
194
+ installing someone's method would mean running their program on the machine
195
+ that holds your Search Console credentials and your service-account key. That
196
+ is a large amount of trust to ask for a list of thresholds.
197
+
198
+ Matching and weighting turned out to cover what people actually want to
199
+ express. A genuinely new *kind* of card — one that reads data no existing rule
200
+ reads — is still a fork, and `docs/PRD.md` carries that as an open question
201
+ rather than a decided one.
202
+
118
203
  ## When a profile cannot be found
119
204
 
120
205
  The run fails, loudly, naming every location that was tried. It does **not**
@@ -72,6 +72,9 @@ DEFAULT_RULES = {
72
72
  "trafficDrop": {"minPriorSessions": 50, "dropRatio": 0.75},
73
73
  "probe": {"minVisibleTextBytes": 500},
74
74
  "metadata": {"maxFindings": 5},
75
+ # Ordering adjustments. Applied by the TypeScript action engine; mirrored
76
+ # here so the two loaders resolve identical config, which a test asserts.
77
+ "priorities": [],
75
78
  }
76
79
 
77
80
  DEFAULT_OPERATING_RULES = {
@@ -103,7 +106,12 @@ def profile_dir(spec):
103
106
 
104
107
 
105
108
  def _merge(base, over):
106
- """Deep merge; `over` wins, and None never overwrites."""
109
+ """Deep merge; `over` wins, None never overwrites, and lists replace.
110
+
111
+ Lists replace rather than concatenate so an instance that sets
112
+ `priorities` replaces the profile's outright. Appending would make a
113
+ profile's priority impossible to remove without forking the profile.
114
+ """
107
115
  out = dict(base)
108
116
  for k, v in (over or {}).items():
109
117
  if v is None:
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Agentic, local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and ranks the next moves. Your LLM writes the proposals; your coding agent works the queue over MCP.",
5
5
  "license": "MIT",
6
6
  "author": "En Dash Consulting (https://endash.us)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo default",
3
- "description": "The model n-seo ships with. Evidence before advice, a 28-day freeze after any metadata change, roughly eight of those a week, and decisions on the trailing 90 days. Every value here is the engine default, written out so you can see what a profile controls.",
3
+ "description": "The model n-seo ships with. Evidence before advice, a 28-day freeze after any metadata change, roughly eight of those a week, and decisions on the trailing 90 days. Every value here is the engine default, written out so you can see what a profile controls. It sets no priorities: an out-of-the-box queue should be ordered by the data alone.",
4
4
  "version": "1.0.0",
5
5
  "operatingRules": {
6
6
  "titleFreezeDays": 28,
@@ -9,12 +9,37 @@
9
9
  "historyMonths": 16
10
10
  },
11
11
  "rules": {
12
- "effortWeight": { "S": 1, "M": 2.5, "L": 5 },
13
- "strikingDistance": { "minPosition": 5, "maxPosition": 15, "minImpressions": 10, "maxRows": 12, "impactPerImpression": 0.06 },
14
- "ctrGap": { "minImpressions": 30, "belowExpectedRatio": 0.5 },
15
- "engagement": { "minSessions": 30, "maxEngagement": 0.25, "maxCards": 3 },
16
- "trafficDrop": { "minPriorSessions": 50, "dropRatio": 0.75 },
17
- "probe": { "minVisibleTextBytes": 500 },
18
- "metadata": { "maxFindings": 5 }
12
+ "effortWeight": {
13
+ "S": 1,
14
+ "M": 2.5,
15
+ "L": 5
16
+ },
17
+ "strikingDistance": {
18
+ "minPosition": 5,
19
+ "maxPosition": 15,
20
+ "minImpressions": 10,
21
+ "maxRows": 12,
22
+ "impactPerImpression": 0.06
23
+ },
24
+ "ctrGap": {
25
+ "minImpressions": 30,
26
+ "belowExpectedRatio": 0.5
27
+ },
28
+ "engagement": {
29
+ "minSessions": 30,
30
+ "maxEngagement": 0.25,
31
+ "maxCards": 3
32
+ },
33
+ "trafficDrop": {
34
+ "minPriorSessions": 50,
35
+ "dropRatio": 0.75
36
+ },
37
+ "probe": {
38
+ "minVisibleTextBytes": 500
39
+ },
40
+ "metadata": {
41
+ "maxFindings": 5
42
+ },
43
+ "priorities": []
19
44
  }
20
45
  }
package/public/styles.css CHANGED
@@ -582,7 +582,22 @@ footer .foot-upd a { color: inherit; text-decoration: underline; }
582
582
  .s-help { margin: 0; font-size: 0.75rem; color: var(--muted); }
583
583
  .s-field { display: flex; flex-direction: column; gap: 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); margin-top: 4px; flex: 1; }
584
584
  .s-field small { font-weight: 500; }
585
- .s-field input[type="text"], .prof-diff { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
585
+ .s-field input[type="text"], .prio-notes { list-style: none; margin: 8px 0 0; padding: 8px 12px; background: var(--warn-soft); border-radius: 8px; }
586
+ .prio-notes li { font-size: 0.78rem; color: var(--warn); line-height: 1.5; }
587
+
588
+ .prof-princ { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; font-size: 0.78rem; color: var(--muted); }
589
+ .prof-princ .chip { font-size: 0.62rem; }
590
+ .princ { margin: 14px 0 0; }
591
+ .princ-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; padding: 12px 16px 16px; }
592
+ .princ-item { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
593
+ .princ-item.hard { border-color: var(--bad-soft); }
594
+ .princ-h { display: flex; align-items: baseline; gap: 7px; font-size: 0.86rem; margin-bottom: 5px; }
595
+ /* .chip ellipsizes at 34ch, and as a flex item it shrinks below that —
596
+ which turned "hard" into "h…". It is a two-word label; never shrink it. */
597
+ .princ-h .chip { flex: none; }
598
+ .princ-item p { margin: 0; font-size: 0.82rem; color: var(--muted); line-height: 1.55; }
599
+
600
+ .prof-diff { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
586
601
  .prof-diff li { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 0.76rem; }
587
602
  .prof-diff .mono { flex: 1 1 100%; color: var(--muted); }
588
603
  .prof-was { font-family: var(--mono); color: var(--muted); text-decoration: line-through; }
package/src/actions.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * then merged with the curated queue in config/backlog.json. Pages listed in
5
5
  * the backlog's `shippedWatch` map turn their rule-derived cards into
6
6
  * "watching" entries — the fix shipped; the data decides what happens next. */
7
- import { SITES, config, type SiteCfg } from "./config.js";
7
+ import { SITES, config, type SiteCfg, type Priority } from "./config.js";
8
8
  import * as data from "./data.js";
9
9
  import { BACKLOG, SHIPPED_WATCH, slug, type Action, type Effort } from "./backlog.js";
10
10
 
@@ -250,12 +250,80 @@ export function actionsFor(site: SiteCfg): Action[] {
250
250
  return [...generated, ...backlog].sort((a, b) => score(b) - score(a));
251
251
  }
252
252
 
253
+ /** The page a card is about, if it names one. Rule-derived cards put
254
+ * "Page: <url>" first in their spec; hygiene and trend cards are about a
255
+ * whole site and have no path to match. */
256
+ function pageOf(a: Action): string | null {
257
+ const line = a.spec?.find((l) => l.startsWith("Page: "));
258
+ if (!line) return null;
259
+ const url = line.slice("Page: ".length).trim();
260
+ try {
261
+ return new URL(url).pathname;
262
+ } catch {
263
+ return url.startsWith("/") ? url : null;
264
+ }
265
+ }
266
+
267
+ function matches(p: Priority, a: Action): boolean {
268
+ const w = p.when ?? {};
269
+ if (w.host && w.host !== a.host) return false;
270
+ if (w.tag && w.tag !== a.tag) return false;
271
+ if (w.kind && !a.kind?.toLowerCase().includes(w.kind.toLowerCase())) return false;
272
+ if (w.pathMatches) {
273
+ const path = pageOf(a);
274
+ if (path === null) return false;
275
+ try {
276
+ if (!new RegExp(w.pathMatches).test(path)) return false;
277
+ } catch {
278
+ // A bad pattern must not silently match everything, and must not take
279
+ // the queue down either.
280
+ return false;
281
+ }
282
+ }
283
+ // A priority with no conditions would apply to the whole queue, which is
284
+ // never what someone means and is a very confusing way to find out.
285
+ return Object.keys(w).length > 0;
286
+ }
287
+
288
+ /** Apply the configured priorities: drop, reweight, and always explain.
289
+ *
290
+ * Order is preserved as a pipeline — later priorities see the impact earlier
291
+ * ones produced — so two boosts on the same card compound, which is what
292
+ * reading them top to bottom implies. */
293
+ export function prioritize(actions: Action[]): Action[] {
294
+ const priorities = (config().rules.priorities ?? []).filter((p) => p?.why && p?.when);
295
+ if (!priorities.length) return actions;
296
+
297
+ const out: Action[] = [];
298
+ for (const a of actions) {
299
+ let card = a;
300
+ let dropped = false;
301
+ const notes: string[] = [];
302
+ for (const p of priorities) {
303
+ if (!matches(p, card)) continue;
304
+ if (p.drop) { dropped = true; break; }
305
+ const by = Number(p.multiply);
306
+ if (!Number.isFinite(by) || by <= 0 || by === 1) continue;
307
+ const before = Number.isFinite(card.impact) ? card.impact : 0;
308
+ card = { ...card, impact: Math.max(0, Math.round(before * by)) };
309
+ notes.push(`${by > 1 ? "Raised" : "Lowered"} ${before} → ${card.impact}: ${p.why}`);
310
+ }
311
+ if (dropped) continue;
312
+ // The card carries its own adjustment. Ordering you cannot see the reason
313
+ // for is ordering you cannot argue with.
314
+ if (notes.length) card = { ...card, spec: [...(card.spec ?? []), ...notes], priorityNotes: notes };
315
+ out.push(card);
316
+ }
317
+ return out;
318
+ }
319
+
253
320
  export function allActions(): Action[] {
254
321
  const sites = SITES();
255
322
  const known = new Set(sites.map((s) => s.host));
256
323
  // Backlog items for hosts no longer in the config still deserve a place.
257
324
  const orphans = BACKLOG().filter((b) => !known.has(b.host));
258
- return [...sites.flatMap((s) => actionsFor(s)), ...orphans].sort((a, b) => score(b) - score(a));
325
+ const all = [...sites.flatMap((s) => actionsFor(s)), ...orphans];
326
+ return prioritize(all).sort((a, b) => score(b) - score(a));
259
327
  }
260
328
 
261
329
  export function actionById(id: string): Action | undefined {
package/src/backlog.ts CHANGED
@@ -39,6 +39,8 @@ export interface Action {
39
39
  tag: string;
40
40
  /** set when a fix already shipped and the data is being watched */
41
41
  watching?: string;
42
+ /** why a configured priority raised or lowered this card's impact */
43
+ priorityNotes?: string[];
42
44
  /** rule = derived from data each request; backlog = curated in config/backlog.json */
43
45
  source?: "rule" | "backlog" | "proposal";
44
46
  }
package/src/config.ts CHANGED
@@ -118,6 +118,8 @@ export interface Rules {
118
118
  };
119
119
  probe: { minVisibleTextBytes: number };
120
120
  metadata: { maxFindings: number };
121
+ /** Ordering adjustments. Applied after the rules run, before the sort. */
122
+ priorities: Priority[];
121
123
  }
122
124
 
123
125
  /** The policy a human follows, as opposed to the numbers a rule sorts by.
@@ -144,6 +146,28 @@ export interface Profile {
144
146
  modules?: Record<string, ModuleCfg>;
145
147
  /** Directory of SKILL.md folders, relative to the profile, copied by `n-seo init`. */
146
148
  skills?: string;
149
+ /** The parts of a method that are not a number.
150
+ *
151
+ * Building the first real profile is what surfaced this. A practitioner's
152
+ * method turned out to be barely distinguishable from ours in thresholds —
153
+ * the striking-distance floor never binds once rows are sorted by
154
+ * impressions — and almost entirely distinguishable in judgement: what to
155
+ * optimise for, what needs a human's approval, what never to automate.
156
+ * None of that is expressible as a threshold, and a profile that cannot
157
+ * carry it is not a method, just a settings file.
158
+ *
159
+ * Shown on the dashboard and written into the instance's CLAUDE.md, so the
160
+ * owner and their agent read the same rules. */
161
+ principles?: ProfilePrinciple[];
162
+ }
163
+
164
+ export interface ProfilePrinciple {
165
+ title: string;
166
+ body: string;
167
+ /** `hard` rules are constraints an agent must not cross; `guide` is
168
+ * judgement it should apply. The dashboard shows them apart because they
169
+ * are different kinds of claim. */
170
+ kind?: "hard" | "guide";
147
171
  }
148
172
 
149
173
  type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
@@ -156,6 +180,7 @@ export const DEFAULT_RULES: Rules = {
156
180
  trafficDrop: { minPriorSessions: 50, dropRatio: 0.75 },
157
181
  probe: { minVisibleTextBytes: 500 },
158
182
  metadata: { maxFindings: 5 },
183
+ priorities: [],
159
184
  };
160
185
 
161
186
  export const DEFAULT_OPERATING_RULES: OperatingRules = {
@@ -165,6 +190,42 @@ export const DEFAULT_OPERATING_RULES: OperatingRules = {
165
190
  historyMonths: 16,
166
191
  };
167
192
 
193
+ /** A declarative adjustment to the queue's ordering.
194
+ *
195
+ * This is n-seo's answer to "custom rules", and it is deliberately not a
196
+ * plugin API. Building the first real profile showed what a practitioner
197
+ * actually wants to express: "prefer pages that lead somewhere", "stop
198
+ * showing me /legal". That is matching and weighting, not arbitrary code —
199
+ * and a profile that ships code means installing someone's method runs their
200
+ * program next to your Search Console credentials.
201
+ *
202
+ * Two hard constraints on the design:
203
+ *
204
+ * - A priority may reorder or hide, never invent. It cannot fabricate a
205
+ * card, so it cannot manufacture evidence.
206
+ * - Every card it touches says so, and says why. A queue that silently
207
+ * reorders itself is a queue whose order you cannot trust, and the whole
208
+ * product rests on showing its working. */
209
+ export interface Priority {
210
+ /** Shown on every card this touches. Required: an unexplained boost is the
211
+ * one thing this must never be. */
212
+ why: string;
213
+ when: {
214
+ /** Exact host, e.g. "example.com". */
215
+ host?: string;
216
+ /** Regular expression against the URL path of the page the card is about. */
217
+ pathMatches?: string;
218
+ /** Rule tag: metadata, striking, ctr-gap, engagement, hygiene, trend. */
219
+ tag?: string;
220
+ /** Substring of the card's `kind`, case-insensitive. */
221
+ kind?: string;
222
+ };
223
+ /** Multiply the impact used for ordering. 1.5 = half again; 0.5 = half. */
224
+ multiply?: number;
225
+ /** Remove the card entirely. For pages you have decided not to work on. */
226
+ drop?: boolean;
227
+ }
228
+
168
229
  export interface Config {
169
230
  name: string;
170
231
  port: number;
@@ -321,6 +382,9 @@ function normalize(raw: Partial<Config>): Config {
321
382
  // engine defaults <- profile <- this instance. The instance always wins,
322
383
  // so a client can always see, and override, where they depart from the
323
384
  // method they installed.
385
+ // `merge` replaces arrays rather than concatenating, so an instance that
386
+ // sets `priorities` replaces the profile's outright. Appending would make
387
+ // a profile's priority impossible to remove without forking it.
324
388
  rules: merge(DEFAULT_RULES, fromProfile?.rules, raw.rules),
325
389
  operatingRules: merge(DEFAULT_OPERATING_RULES, fromProfile?.operatingRules, raw.operatingRules),
326
390
  };
@@ -8,7 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import {
10
10
  CONFIG_PATH, EXAMPLE_CONFIG_PATH, PROFILE_FILE, DEFAULT_RULES, DEFAULT_OPERATING_RULES,
11
- ROOT, config, loadProfile, type Profile,
11
+ ROOT, config, loadProfile, type Profile, type ProfilePrinciple,
12
12
  } from "./config.js";
13
13
 
14
14
  export interface Departure {
@@ -28,6 +28,7 @@ export interface ProfileReport {
28
28
  version?: string;
29
29
  dir?: string;
30
30
  departures: Departure[];
31
+ principles: ProfilePrinciple[];
31
32
  }
32
33
 
33
34
  /** Flatten a nested tree to dotted leaves, so two layers can be diffed. */
@@ -89,6 +90,7 @@ export function profileReport(): ProfileReport {
89
90
  version: profile?.version,
90
91
  dir: loaded?.dir,
91
92
  departures,
93
+ principles: (profile?.principles ?? []).filter((p) => p?.title && p?.body),
92
94
  };
93
95
  }
94
96
 
package/src/settings.tsx CHANGED
@@ -91,6 +91,16 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
91
91
  <code>"profile"</code> in the config, or point it at a directory or an installed package.
92
92
  </p>
93
93
  )}
94
+ {prof.principles.length > 0 && (
95
+ <>
96
+ <p class="sub">{prof.principles.length} principle{prof.principles.length === 1 ? "" : "s"}, shown in full on <a href="/actions">Actions</a>.</p>
97
+ <ul class="prof-princ">
98
+ {prof.principles.map((pr) => (
99
+ <li><span class={`chip ${pr.kind === "hard" ? "bad" : ""}`}>{pr.kind === "hard" ? "hard" : "guide"}</span> {pr.title}</li>
100
+ ))}
101
+ </ul>
102
+ </>
103
+ )}
94
104
  {prof.departures.length > 0 && (
95
105
  <>
96
106
  <p class="sub">Changed here, against the {prof.spec ? "profile" : "engine defaults"}:</p>
package/src/views.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  import type { FC, PropsWithChildren } from "hono/jsx";
3
3
  import { SITES, ENGINE_VERSION, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
4
4
  import * as data from "./data.js";
5
+ import { profileReport } from "./profile-report.js";
5
6
  import { actionsFor, allActions, type Action } from "./actions.js";
6
7
  import { insights as loadInsights } from "./insights.js";
7
8
 
@@ -140,7 +141,18 @@ export const ActionModal: FC<{ a: Action; id: string }> = ({ a, id }) => (
140
141
  <span class={`chip effort e-${a.effort}`}>{effortLabel(a.effort, true)}</span>
141
142
  <span class="chip tag">{a.tag}</span>
142
143
  <span class="chip">{a.source === "backlog" ? "curated" : a.source === "proposal" ? "proposed" : "data-derived"}</span>
144
+ {a.priorityNotes?.length ? (
145
+ <span class="chip warn" title="a configured priority changed where this sits in the queue">reweighted</span>
146
+ ) : null}
143
147
  </div>
148
+ {a.priorityNotes?.length ? (
149
+ // The impact number decides the order, so a card whose number was
150
+ // adjusted has to say so on its face. Ordering you cannot see the
151
+ // reason for is ordering you cannot argue with.
152
+ <ul class="prio-notes">
153
+ {a.priorityNotes.map((n) => <li>{n}</li>)}
154
+ </ul>
155
+ ) : null}
144
156
  {a.watching && <p class="action-watching">⏳ {a.watching}</p>}
145
157
  <h4>Why (the data)</h4>
146
158
  <p>{a.why}</p>
@@ -265,6 +277,9 @@ const ProposalCard: FC<{ p: data.ScanOutput["proposals"][number]; index: number
265
277
 
266
278
  export const ActionsPage: FC<{ flash?: string }> = ({ flash }) => {
267
279
  const scan = data.opportunityScan();
280
+ const prof = profileReport();
281
+ const principles = prof.principles;
282
+ const profileName = prof.name;
268
283
  return (
269
284
  <>
270
285
  <div class="strip-head">
@@ -274,6 +289,21 @@ export const ActionsPage: FC<{ flash?: string }> = ({ flash }) => {
274
289
  <p class="sub">Data-derived rules (90-day window) + your curated queue (<code>config/backlog.json</code>). Click any item for the full spec. Impact numbers rank the queue — they are estimates for ordering, not forecasts.</p>
275
290
  {flash && <p class="flash">{flash}</p>}
276
291
 
292
+ {principles.length > 0 && (
293
+ <details class="wb-fold princ">
294
+ <summary>
295
+ Operating principles <small>— from the {profileName} profile; {principles.filter((p) => p.kind === "hard").length} are constraints, not advice</small>
296
+ </summary>
297
+ <div class="princ-list">
298
+ {principles.map((p) => (
299
+ <div class={`princ-item ${p.kind === "hard" ? "hard" : ""}`}>
300
+ <div class="princ-h"><span class={`chip ${p.kind === "hard" ? "bad" : ""}`}>{p.kind === "hard" ? "hard" : "guide"}</span> <b>{p.title}</b></div>
301
+ <p>{p.body}</p>
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </details>
306
+ )}
277
307
  {scan && (scan.proposals.length > 0 || scan.verdicts.length > 0) && (
278
308
  <div class="scan-panel" id="proposed">
279
309
  <h2>Proposed by the opportunity scan <small>· {scan.generated} — rising queries no queue item covers, turned into candidate moves. Accepting copies a proposal into your queue; nothing self-modifies it.</small></h2>