n-seo 0.4.1 → 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,91 @@ 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
+
53
+ ## [0.5.0] - 2026-09-14
54
+
55
+ The operating model was ours, hardcoded. Now it is a file you can install.
56
+
57
+ ### Added
58
+ - **Profiles.** One config key — `"profile"` — swaps every threshold and
59
+ policy the engine uses for someone else's. Resolves a name shipped with the
60
+ engine, a path, or an installed package, in that order. Three ship:
61
+ `default` (the engine's own model, written out so you can read what a
62
+ profile controls), `patient` and `aggressive`.
63
+ - **Three layers, and the instance always wins**: engine defaults, then the
64
+ profile, then this instance. That is the part that makes a profile usable
65
+ for client work — a client running their agency's method can always see,
66
+ and change, exactly where their setup departs from it. `n-seo profile`
67
+ prints that list; the Settings page shows it beside the profile's name.
68
+ - **A profile that cannot be resolved fails the load**, naming every location
69
+ tried, rather than falling back to the defaults. Discovering months later
70
+ that a client's instance quietly stopped applying your method is worse than
71
+ an error on the morning you typed the name wrong. `doctor` reports it too.
72
+ - **`rules` in config.** Every number the action engine ranks by — the
73
+ striking-distance band, the impression floors, the CTR ratio, the
74
+ engagement thresholds, the drop percentage, the effort weights — was a
75
+ literal in `src/actions.ts` and `src/data.ts`. Disagreeing with any of them
76
+ meant editing the engine and then living with the merge on every upgrade.
77
+ - **`operatingRules` in config**: the freeze window, the weekly batch size,
78
+ the decision window, the history length. One place, so changing the freeze
79
+ changes it everywhere it is stated rather than in six that drift.
80
+ - `docs/PROFILES.md`, and a section on the site for people who do this for a
81
+ living.
82
+
83
+ ### Decided
84
+ - A profile is data and may not ship executable code. Installing someone's
85
+ method should not mean running their code on the machine that holds your
86
+ Search Console credentials. Custom rules — new kinds of card rather than new
87
+ numbers for existing ones — remain a fork, and the PRD carries the open
88
+ question.
89
+
90
+ ### Fixed
91
+ - The TypeScript and Python loaders now resolve profiles identically, asserted
92
+ by a test that runs both against every shipped profile. A dashboard saying
93
+ the freeze is 28 days while the daily log says 56 would be worse than either
94
+ number being wrong.
95
+
11
96
  ## [0.4.1] - 2026-09-13
12
97
 
13
98
  ### Added
@@ -401,7 +486,9 @@ First public release.
401
486
  `n-seo upgrade` gate — without changing the reported test counts, and an
402
487
  in-place install ran those assertions against the owner's live data.
403
488
 
404
- [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...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
491
+ [0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
405
492
  [0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
406
493
  [0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
407
494
  [0.3.3]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.2...v0.3.3
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  An agentic, local-first control plane for growing organic traffic to your own
10
10
  sites — classic search (SEO), answer engines (AEO) and AI assistants that cite
11
- sources (GEO) — without paying an agency to read Search Console for you.
11
+ sources (GEO). Your sites, your data, your call.
12
12
 
13
13
  It pulls Search Console and GA4 into local JSON, probes your live sites for the
14
14
  things that quietly break (robots, sitemap, soft 404s, blocked AI crawlers,
package/bin/n-seo.mjs CHANGED
@@ -41,6 +41,7 @@ usage: n-seo <command> [--instance <dir>] [args...]
41
41
  check engine self-test: typecheck + unit tests
42
42
  upgrade update the engine in place, whatever way it was installed;
43
43
  --check reports what is available without changing anything
44
+ profile the active profile, and every value this instance overrides
44
45
  version engine version, commit, paths, mode
45
46
  help this text
46
47
 
@@ -143,6 +144,41 @@ function missingDeps(cmd, what) {
143
144
  return 1;
144
145
  }
145
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
+
146
182
  /* ---------- init ---------- */
147
183
 
148
184
  /** Files that mean "this directory is an application", not a place to keep
@@ -303,7 +339,7 @@ Skills in \`.claude/skills/\` cover the routine work — start with
303
339
  /actions until a human accepts them.
304
340
  - **Site changes ship as branches and pull requests** in the site's own repo,
305
341
  never committed straight to its main branch.
306
- `);
342
+ ${profilePrinciples(dir)}`);
307
343
 
308
344
  const envExample = path.join(ROOT, ".env.example");
309
345
  put(".env", fs.existsSync(envExample) ? fs.readFileSync(envExample, "utf8") : "");
@@ -548,6 +584,7 @@ switch (cmd) {
548
584
  case "init":
549
585
  code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
550
586
  break;
587
+ case "profile":
551
588
  case "start":
552
589
  case "dev":
553
590
  case "mcp": {
@@ -558,7 +595,9 @@ switch (cmd) {
558
595
  code = missingDeps(cmd, "the engine's dependencies");
559
596
  break;
560
597
  }
561
- const entry = cmd === "mcp" ? "src/mcp-stdio.ts" : "src/server.tsx";
598
+ const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
599
+ : cmd === "profile" ? "src/profile-cli.ts"
600
+ : "src/server.tsx";
562
601
  const args = cmd === "dev" ? ["watch", entry] : [entry];
563
602
  code = run(process.execPath, [bin, ...args, ...rest], instance);
564
603
  break;
package/docs/PRD.md CHANGED
@@ -10,16 +10,19 @@ check. Status markers: **[shipped]**, **[planned]**, **[idea]**.
10
10
 
11
11
  n-seo is a local-first control plane for organic growth across one or more
12
12
  websites: classic search (SEO), answer engines (AEO) and generative engines
13
- that cite sources (GEO). It replaces the monthly agency read-out with a loop
13
+ that cite sources (GEO). It replaces the monthly read-out with a loop
14
14
  that runs every morning on the owner's machine: pull Search Console and GA4,
15
15
  probe the live sites, turn the data into a ranked queue of concrete actions
16
16
  with evidence attached, and measure yesterday's changes.
17
17
 
18
18
  ## Users
19
19
 
20
- - **A site owner or small team** who wants to hone their own SEO practice
21
- without paying an agency: developers, indie makers, consultancies, small
22
- businesses with a technical person.
20
+ - **A site owner or small team** who wants to run their own search practice
21
+ and understand it: developers, indie makers, consultancies, small businesses
22
+ with a technical person. Note that agencies and consultancies are users
23
+ here, not the thing being displaced — the copy should never imply otherwise,
24
+ and the tool is as useful to someone doing this for clients as for
25
+ themselves.
23
26
  - **An operator running several sites** (a portfolio, an agency serving its
24
27
  own clients) who needs one queue across all of them.
25
28
  - **An AI agent** (Claude Code, Claude Desktop, any MCP client) acting for
@@ -136,14 +139,43 @@ a fresh checkout runs.
136
139
  - Acceptance: `config/backlog.json` is merged and hot-reloaded without a
137
140
  restart, and a syntax error keeps the last good queue.
138
141
 
139
- ## Feature: Rule thresholds in config [planned]
140
-
141
- Move the numeric thresholds (min impressions, position band, engagement
142
- floor, drop percentage, effort weights) into an optional `rules` block of
143
- the config with the current values as defaults.
144
-
145
- - Acceptance: every threshold has a documented default and a config
146
- override; tests cover an override changing a rule's output.
142
+ ## Feature: Profiles — a method you can install [shipped]
143
+
144
+ n-seo ships one operating model; a practitioner has their own. A **profile**
145
+ packages thresholds, policy numbers and module defaults so a method can be
146
+ published once and installed many times. Full reference: `docs/PROFILES.md`.
147
+
148
+ - Acceptance: `"profile": "<built-in | path | package>"` resolves in that
149
+ order, and every value remains overridable by the instance.
150
+ - Acceptance: a profile that cannot be resolved fails the load with a message
151
+ naming each location tried, rather than falling back to the defaults.
152
+ - Acceptance: `n-seo profile` prints the active profile and every value this
153
+ instance overrides; the Settings page shows the same, and `doctor` reports
154
+ an unresolvable profile before the daily run reaches it.
155
+ - Acceptance: the TypeScript and Python loaders resolve every shipped profile
156
+ to identical numbers, asserted by a test that runs both.
157
+ - Acceptance: `default`, `patient` and `aggressive` ship with the engine, and
158
+ `default` restates the engine defaults exactly — asserted, so the two cannot
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.
167
+ - Decided: a profile is data only and may not ship executable code. Installing
168
+ a method should not mean running its author's code on the machine holding
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.
173
+
174
+ ## Feature: Rule thresholds in config [shipped]
175
+
176
+ - Acceptance: every threshold the action engine ranks by lives in `rules`,
177
+ with the engine defaults documented in `src/config.ts` and mirrored in
178
+ `ingest/seo_config.py`; tests cover an override changing a rule's output.
147
179
 
148
180
  # Epic: Dashboard [shipped]
149
181
 
@@ -0,0 +1,220 @@
1
+ # Profiles — a method you can install
2
+
3
+ n-seo ships with an opinionated model: a 28-day freeze after any metadata
4
+ change, roughly eight of those a week, striking distance meaning positions
5
+ 5–15, decisions on the trailing 90 days. Those numbers are a position, not a
6
+ law, and if you do this for a living yours are probably different.
7
+
8
+ A **profile** is that position, packaged. One config key, and every threshold
9
+ and policy the engine uses comes from your profile instead of ours.
10
+
11
+ ```jsonc
12
+ {
13
+ "profile": "patient",
14
+ "sites": [ ... ]
15
+ }
16
+ ```
17
+
18
+ ## The three layers
19
+
20
+ ```
21
+ engine defaults what n-seo believes, in src/config.ts
22
+ ↓ overridden by
23
+ the profile what you or your agency believes
24
+ ↓ overridden by
25
+ this instance what this one client needs
26
+ ```
27
+
28
+ The instance always wins. That matters more than it sounds: a client running
29
+ their agency's profile can always see, and change, exactly where their setup
30
+ departs from it. `n-seo profile` prints that list, and the Settings page shows
31
+ it beside the profile's name.
32
+
33
+ ```
34
+ $ n-seo profile
35
+ profile Patient 1.0.0
36
+ spec patient
37
+ from /opt/n-seo/profiles/patient
38
+
39
+ this instance overrides 2 value(s):
40
+
41
+ operatingRules.titleFreezeDays 56 → 30
42
+ rules.strikingDistance.maxRows 6 → 9
43
+ ```
44
+
45
+ ## What a profile can set
46
+
47
+ `n-seo.profile.json`, at the root of a directory or a package:
48
+
49
+ ```jsonc
50
+ {
51
+ "name": "Acme Search",
52
+ "description": "How we run search for retail clients.",
53
+ "version": "1.2.0",
54
+
55
+ // The policy a human follows.
56
+ "operatingRules": {
57
+ "titleFreezeDays": 28, // hands off a page's metadata this long
58
+ "metadataChangesPerWeek": 8, // batch size across the portfolio
59
+ "decisionWindowDays": 90, // decisions ride this window
60
+ "historyMonths": 16 // the long view, for totals only
61
+ },
62
+
63
+ // The numbers the action engine ranks by.
64
+ "rules": {
65
+ "effortWeight": { "S": 1, "M": 2.5, "L": 5 },
66
+ "strikingDistance": { "minPosition": 5, "maxPosition": 15, "minImpressions": 10,
67
+ "maxRows": 12, "impactPerImpression": 0.06 },
68
+ "ctrGap": { "minImpressions": 30, "belowExpectedRatio": 0.5 },
69
+ "engagement": { "minSessions": 30, "maxEngagement": 0.25, "maxCards": 3 },
70
+ "trafficDrop": { "minPriorSessions": 50, "dropRatio": 0.75 },
71
+ "probe": { "minVisibleTextBytes": 500 },
72
+ "metadata": { "maxFindings": 5 }
73
+ },
74
+
75
+ // Module defaults. The instance can still switch any of them.
76
+ "modules": {
77
+ "indexNow": { "enabled": true }
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
+ ]
89
+ }
90
+ ```
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
+
112
+ Set only what you disagree with. Anything absent is inherited, so a profile
113
+ that changes one threshold is three lines long.
114
+
115
+ ## Shipped with the engine
116
+
117
+ | spec | what it is for |
118
+ |---|---|
119
+ | `default` | The engine's own model, written out so you can read what a profile controls. |
120
+ | `patient` | Low traffic, long sales cycle, or an owner who would rather ship three good changes a month than thirty. Longer freeze, smaller batches, a higher bar before anything is called a problem. |
121
+ | `aggressive` | A large site with traffic to spare, where missing an opportunity costs more than a wasted afternoon. Wider band, lower floors, longer queue. Keeps the 28-day freeze — that one is not a preference. |
122
+
123
+ `n-seo profile` lists them.
124
+
125
+ ## Writing your own
126
+
127
+ A profile is a directory with an `n-seo.profile.json` in it. Three ways to
128
+ point at one:
129
+
130
+ ```jsonc
131
+ "profile": "patient" // shipped with the engine
132
+ "profile": "./profiles/acme" // a path, relative to the instance
133
+ "profile": "n-seo-profile-acme" // an installed package
134
+ ```
135
+
136
+ To publish one, make it a package whose root holds the file:
137
+
138
+ ```
139
+ n-seo-profile-acme/
140
+ ├── package.json
141
+ └── n-seo.profile.json
142
+ ```
143
+
144
+ Then `npm i n-seo-profile-acme` in the instance and name it in the config.
145
+ Versioning the package versions the method, which is the point: "we moved you
146
+ to Acme Search 2.0" is a sentence a client can check.
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
+
203
+ ## When a profile cannot be found
204
+
205
+ The run fails, loudly, naming every location that was tried. It does **not**
206
+ fall back to the engine defaults. Someone running a client's portfolio on
207
+ their agency's method should never discover it quietly stopped applying —
208
+ a wrong answer you can see beats a plausible one you cannot.
209
+
210
+ `n-seo doctor` reports the same thing before the daily run gets there.
211
+
212
+ ## What a profile cannot do
213
+
214
+ It cannot ship executable code. A profile is data: thresholds, policy numbers
215
+ and module defaults. Installing someone's method should not mean running
216
+ their code on the machine that holds your Search Console credentials.
217
+
218
+ Custom *rules* — new kinds of card, not new numbers for existing ones — are a
219
+ real gap, and the honest answer today is to fork the engine. `docs/PRD.md`
220
+ carries the open question.
@@ -57,6 +57,70 @@ MODULE_DEFAULTS = {
57
57
  "delete": False, "dryRun": False, "env": {}},
58
58
  }
59
59
 
60
+ PROFILE_FILE = "n-seo.profile.json"
61
+
62
+ # Mirrors DEFAULT_RULES / DEFAULT_OPERATING_RULES in src/config.ts. The
63
+ # TypeScript side is where the action engine reads them; Python needs them so
64
+ # the daily log, the digests and doctor state the same numbers the dashboard
65
+ # does, rather than a second set that drifts.
66
+ DEFAULT_RULES = {
67
+ "effortWeight": {"S": 1, "M": 2.5, "L": 5},
68
+ "strikingDistance": {"minPosition": 5, "maxPosition": 15, "minImpressions": 10,
69
+ "maxRows": 12, "impactPerImpression": 0.06},
70
+ "ctrGap": {"minImpressions": 30, "belowExpectedRatio": 0.5},
71
+ "engagement": {"minSessions": 30, "maxEngagement": 0.25, "maxCards": 3},
72
+ "trafficDrop": {"minPriorSessions": 50, "dropRatio": 0.75},
73
+ "probe": {"minVisibleTextBytes": 500},
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": [],
78
+ }
79
+
80
+ DEFAULT_OPERATING_RULES = {
81
+ "titleFreezeDays": 28,
82
+ "metadataChangesPerWeek": 8,
83
+ "decisionWindowDays": 90,
84
+ "historyMonths": 16,
85
+ }
86
+
87
+
88
+ def profile_dir(spec):
89
+ """Where a profile spec resolves to, or None. Mirrors resolveProfileDir."""
90
+ if not spec:
91
+ return None
92
+ if "/" not in spec and "\\" not in spec:
93
+ built = ROOT / "profiles" / spec
94
+ if (built / PROFILE_FILE).exists():
95
+ return built
96
+ as_path = Path(spec) if Path(spec).is_absolute() else (INSTANCE / spec)
97
+ if (as_path / PROFILE_FILE).exists():
98
+ return as_path.resolve()
99
+ # An installed package, without importing node: node_modules beside the
100
+ # instance, then beside the engine.
101
+ for base in (INSTANCE, ROOT):
102
+ cand = base / "node_modules" / spec
103
+ if (cand / PROFILE_FILE).exists():
104
+ return cand.resolve()
105
+ return None
106
+
107
+
108
+ def _merge(base, over):
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
+ """
115
+ out = dict(base)
116
+ for k, v in (over or {}).items():
117
+ if v is None:
118
+ continue
119
+ cur = out.get(k)
120
+ out[k] = _merge(cur, v) if isinstance(cur, dict) and isinstance(v, dict) else v
121
+ return out
122
+
123
+
60
124
  _cache = None
61
125
 
62
126
 
@@ -73,9 +137,22 @@ def load(force: bool = False) -> dict:
73
137
  return _cache
74
138
  path = CONFIG_PATH if CONFIG_PATH.exists() else EXAMPLE_PATH
75
139
  raw = json.loads(path.read_text(encoding="utf-8"))
76
- modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {})} for k in MODULE_KEYS}
77
- for k, v in (raw.get("modules") or {}).items():
78
- modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(v or {})}
140
+ # engine defaults <- profile <- this instance, for modules and both rule
141
+ # blocks. The instance always wins.
142
+ prof = {}
143
+ pdir = profile_dir(raw.get("profile"))
144
+ if pdir:
145
+ try:
146
+ prof = json.loads((pdir / PROFILE_FILE).read_text(encoding="utf-8"))
147
+ except (OSError, ValueError):
148
+ prof = {}
149
+
150
+ pmods = prof.get("modules") or {}
151
+ modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(pmods.get(k) or {})}
152
+ for k in MODULE_KEYS}
153
+ for k, v in {**pmods, **(raw.get("modules") or {})}.items():
154
+ modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}),
155
+ **(pmods.get(k) or {}), **((raw.get("modules") or {}).get(k) or {})}
79
156
  sites = []
80
157
  for s in raw.get("sites") or []:
81
158
  if not s.get("host"):
@@ -109,6 +186,10 @@ def load(force: bool = False) -> dict:
109
186
  "conversions": conv if conv.get("site") else None,
110
187
  "participation": raw.get("participation") or {},
111
188
  "modules": modules,
189
+ "profile": raw.get("profile"),
190
+ "rules": _merge(_merge(DEFAULT_RULES, prof.get("rules")), raw.get("rules")),
191
+ "operatingRules": _merge(_merge(DEFAULT_OPERATING_RULES, prof.get("operatingRules")),
192
+ raw.get("operatingRules")),
112
193
  "gscExtraProperties": str_list(raw.get("gscExtraProperties")),
113
194
  "hooks": hooks,
114
195
  }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "name": "My sites",
3
+ "_profile": "Optional. A method to inherit: a name shipped with the engine (default, patient, aggressive), a path, or an installed package. Anything you set below still wins. See docs/PROFILES.md.",
3
4
  "port": 4600,
4
5
  "google": {
5
6
  "auth": "service-account-key",
Binary file
package/ops/doctor.py CHANGED
@@ -55,6 +55,30 @@ def check_engine():
55
55
  report("OK", "update check has not run yet", "it runs with the daily run")
56
56
 
57
57
 
58
+ def check_profile(cfg):
59
+ """A profile that cannot be resolved must be loud.
60
+
61
+ Someone running a client's portfolio on their agency's method should never
62
+ discover it quietly stopped applying and the engine defaults took over.
63
+ """
64
+ spec = cfg.get("profile")
65
+ if not spec:
66
+ return
67
+ print("profile")
68
+ d = seo_config.profile_dir(spec)
69
+ if not d:
70
+ report("FAIL", f"profile {spec!r} not found",
71
+ "install it, fix the path, or remove \"profile\" from the config")
72
+ return
73
+ try:
74
+ meta = json.loads((d / "n-seo.profile.json").read_text(encoding="utf-8"))
75
+ except (OSError, ValueError) as exc:
76
+ report("FAIL", f"profile {spec!r} is unreadable", str(exc)[:120])
77
+ return
78
+ report("OK", f"{meta.get('name', spec)}" + (f" {meta['version']}" if meta.get("version") else ""))
79
+ report("OK", f"from {d}")
80
+
81
+
58
82
  def check_config():
59
83
  print("config")
60
84
  if seo_config.using_example():
@@ -309,6 +333,8 @@ def check_modules(cfg):
309
333
  def main():
310
334
  check_engine()
311
335
  cfg = check_config()
336
+ if cfg is not None:
337
+ check_profile(cfg)
312
338
  check_tools()
313
339
  if cfg is None:
314
340
  return 1