n-seo 0.4.0 → 0.5.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,62 @@ uses [Semantic Versioning](https://semver.org/).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.5.0] - 2026-09-14
12
+
13
+ The operating model was ours, hardcoded. Now it is a file you can install.
14
+
15
+ ### Added
16
+ - **Profiles.** One config key — `"profile"` — swaps every threshold and
17
+ policy the engine uses for someone else's. Resolves a name shipped with the
18
+ engine, a path, or an installed package, in that order. Three ship:
19
+ `default` (the engine's own model, written out so you can read what a
20
+ profile controls), `patient` and `aggressive`.
21
+ - **Three layers, and the instance always wins**: engine defaults, then the
22
+ profile, then this instance. That is the part that makes a profile usable
23
+ for client work — a client running their agency's method can always see,
24
+ and change, exactly where their setup departs from it. `n-seo profile`
25
+ prints that list; the Settings page shows it beside the profile's name.
26
+ - **A profile that cannot be resolved fails the load**, naming every location
27
+ tried, rather than falling back to the defaults. Discovering months later
28
+ that a client's instance quietly stopped applying your method is worse than
29
+ an error on the morning you typed the name wrong. `doctor` reports it too.
30
+ - **`rules` in config.** Every number the action engine ranks by — the
31
+ striking-distance band, the impression floors, the CTR ratio, the
32
+ engagement thresholds, the drop percentage, the effort weights — was a
33
+ literal in `src/actions.ts` and `src/data.ts`. Disagreeing with any of them
34
+ meant editing the engine and then living with the merge on every upgrade.
35
+ - **`operatingRules` in config**: the freeze window, the weekly batch size,
36
+ the decision window, the history length. One place, so changing the freeze
37
+ changes it everywhere it is stated rather than in six that drift.
38
+ - `docs/PROFILES.md`, and a section on the site for people who do this for a
39
+ living.
40
+
41
+ ### Decided
42
+ - A profile is data and may not ship executable code. Installing someone's
43
+ method should not mean running their code on the machine that holds your
44
+ Search Console credentials. Custom rules — new kinds of card rather than new
45
+ numbers for existing ones — remain a fork, and the PRD carries the open
46
+ question.
47
+
48
+ ### Fixed
49
+ - The TypeScript and Python loaders now resolve profiles identically, asserted
50
+ by a test that runs both against every shipped profile. A dashboard saying
51
+ the freeze is 28 days while the daily log says 56 would be worse than either
52
+ number being wrong.
53
+
54
+ ## [0.4.1] - 2026-09-13
55
+
56
+ ### Added
57
+ - **Every page says which engine built it.** The footer now carries
58
+ `n-seo <version>`, and when a newer one is published it carries the upgrade
59
+ command and a link to the release notes beside it. This matters most on a
60
+ published mirror: `/settings` is deliberately not exported, because it shows
61
+ local paths and the service-account email, so before this a mirror named no
62
+ version anywhere. The notice is baked in at export time from the file the
63
+ daily run writes — polling the registry from the browser would put every
64
+ viewer of a shared mirror onto npmjs.com on every page view, to learn
65
+ something that changes once a day.
66
+
11
67
  ## [0.4.0] - 2026-09-12
12
68
 
13
69
  Upgrading used to be something you found out about by accident. This release
@@ -388,7 +444,9 @@ First public release.
388
444
  `n-seo upgrade` gate — without changing the reported test counts, and an
389
445
  in-place install ran those assertions against the owner's live data.
390
446
 
391
- [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...HEAD
447
+ [Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.5.0...HEAD
448
+ [0.5.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...v0.5.0
449
+ [0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
392
450
  [0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
393
451
  [0.3.3]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.2...v0.3.3
394
452
  [0.3.2]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.1...v0.3.2
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
 
@@ -548,6 +549,7 @@ switch (cmd) {
548
549
  case "init":
549
550
  code = init(rest.find((a) => !a.startsWith("-")) ?? flag ?? process.cwd(), rest.includes("--force"));
550
551
  break;
552
+ case "profile":
551
553
  case "start":
552
554
  case "dev":
553
555
  case "mcp": {
@@ -558,7 +560,9 @@ switch (cmd) {
558
560
  code = missingDeps(cmd, "the engine's dependencies");
559
561
  break;
560
562
  }
561
- const entry = cmd === "mcp" ? "src/mcp-stdio.ts" : "src/server.tsx";
563
+ const entry = cmd === "mcp" ? "src/mcp-stdio.ts"
564
+ : cmd === "profile" ? "src/profile-cli.ts"
565
+ : "src/server.tsx";
562
566
  const args = cmd === "dev" ? ["watch", entry] : [entry];
563
567
  code = run(process.execPath, [bin, ...args, ...rest], instance);
564
568
  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,34 @@ 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
+ - Decided: a profile is data only and may not ship executable code. Installing
161
+ 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.
164
+
165
+ ## Feature: Rule thresholds in config [shipped]
166
+
167
+ - Acceptance: every threshold the action engine ranks by lives in `rules`,
168
+ with the engine defaults documented in `src/config.ts` and mirrored in
169
+ `ingest/seo_config.py`; tests cover an override changing a rule's output.
147
170
 
148
171
  # Epic: Dashboard [shipped]
149
172
 
@@ -0,0 +1,135 @@
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
+ ```
81
+
82
+ Set only what you disagree with. Anything absent is inherited, so a profile
83
+ that changes one threshold is three lines long.
84
+
85
+ ## Shipped with the engine
86
+
87
+ | spec | what it is for |
88
+ |---|---|
89
+ | `default` | The engine's own model, written out so you can read what a profile controls. |
90
+ | `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. |
91
+ | `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. |
92
+
93
+ `n-seo profile` lists them.
94
+
95
+ ## Writing your own
96
+
97
+ A profile is a directory with an `n-seo.profile.json` in it. Three ways to
98
+ point at one:
99
+
100
+ ```jsonc
101
+ "profile": "patient" // shipped with the engine
102
+ "profile": "./profiles/acme" // a path, relative to the instance
103
+ "profile": "n-seo-profile-acme" // an installed package
104
+ ```
105
+
106
+ To publish one, make it a package whose root holds the file:
107
+
108
+ ```
109
+ n-seo-profile-acme/
110
+ ├── package.json
111
+ └── n-seo.profile.json
112
+ ```
113
+
114
+ Then `npm i n-seo-profile-acme` in the instance and name it in the config.
115
+ Versioning the package versions the method, which is the point: "we moved you
116
+ to Acme Search 2.0" is a sentence a client can check.
117
+
118
+ ## When a profile cannot be found
119
+
120
+ The run fails, loudly, naming every location that was tried. It does **not**
121
+ fall back to the engine defaults. Someone running a client's portfolio on
122
+ their agency's method should never discover it quietly stopped applying —
123
+ a wrong answer you can see beats a plausible one you cannot.
124
+
125
+ `n-seo doctor` reports the same thing before the daily run gets there.
126
+
127
+ ## What a profile cannot do
128
+
129
+ It cannot ship executable code. A profile is data: thresholds, policy numbers
130
+ and module defaults. Installing someone's method should not mean running
131
+ their code on the machine that holds your Search Console credentials.
132
+
133
+ Custom *rules* — new kinds of card, not new numbers for existing ones — are a
134
+ real gap, and the honest answer today is to fork the engine. `docs/PRD.md`
135
+ carries the open question.
@@ -57,6 +57,62 @@ 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
+ }
76
+
77
+ DEFAULT_OPERATING_RULES = {
78
+ "titleFreezeDays": 28,
79
+ "metadataChangesPerWeek": 8,
80
+ "decisionWindowDays": 90,
81
+ "historyMonths": 16,
82
+ }
83
+
84
+
85
+ def profile_dir(spec):
86
+ """Where a profile spec resolves to, or None. Mirrors resolveProfileDir."""
87
+ if not spec:
88
+ return None
89
+ if "/" not in spec and "\\" not in spec:
90
+ built = ROOT / "profiles" / spec
91
+ if (built / PROFILE_FILE).exists():
92
+ return built
93
+ as_path = Path(spec) if Path(spec).is_absolute() else (INSTANCE / spec)
94
+ if (as_path / PROFILE_FILE).exists():
95
+ return as_path.resolve()
96
+ # An installed package, without importing node: node_modules beside the
97
+ # instance, then beside the engine.
98
+ for base in (INSTANCE, ROOT):
99
+ cand = base / "node_modules" / spec
100
+ if (cand / PROFILE_FILE).exists():
101
+ return cand.resolve()
102
+ return None
103
+
104
+
105
+ def _merge(base, over):
106
+ """Deep merge; `over` wins, and None never overwrites."""
107
+ out = dict(base)
108
+ for k, v in (over or {}).items():
109
+ if v is None:
110
+ continue
111
+ cur = out.get(k)
112
+ out[k] = _merge(cur, v) if isinstance(cur, dict) and isinstance(v, dict) else v
113
+ return out
114
+
115
+
60
116
  _cache = None
61
117
 
62
118
 
@@ -73,9 +129,22 @@ def load(force: bool = False) -> dict:
73
129
  return _cache
74
130
  path = CONFIG_PATH if CONFIG_PATH.exists() else EXAMPLE_PATH
75
131
  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 {})}
132
+ # engine defaults <- profile <- this instance, for modules and both rule
133
+ # blocks. The instance always wins.
134
+ prof = {}
135
+ pdir = profile_dir(raw.get("profile"))
136
+ if pdir:
137
+ try:
138
+ prof = json.loads((pdir / PROFILE_FILE).read_text(encoding="utf-8"))
139
+ except (OSError, ValueError):
140
+ prof = {}
141
+
142
+ pmods = prof.get("modules") or {}
143
+ modules = {k: {"enabled": False, **MODULE_DEFAULTS.get(k, {}), **(pmods.get(k) or {})}
144
+ for k in MODULE_KEYS}
145
+ for k, v in {**pmods, **(raw.get("modules") or {})}.items():
146
+ modules[k] = {"enabled": False, **MODULE_DEFAULTS.get(k, {}),
147
+ **(pmods.get(k) or {}), **((raw.get("modules") or {}).get(k) or {})}
79
148
  sites = []
80
149
  for s in raw.get("sites") or []:
81
150
  if not s.get("host"):
@@ -109,6 +178,10 @@ def load(force: bool = False) -> dict:
109
178
  "conversions": conv if conv.get("site") else None,
110
179
  "participation": raw.get("participation") or {},
111
180
  "modules": modules,
181
+ "profile": raw.get("profile"),
182
+ "rules": _merge(_merge(DEFAULT_RULES, prof.get("rules")), raw.get("rules")),
183
+ "operatingRules": _merge(_merge(DEFAULT_OPERATING_RULES, prof.get("operatingRules")),
184
+ raw.get("operatingRules")),
112
185
  "gscExtraProperties": str_list(raw.get("gscExtraProperties")),
113
186
  "hooks": hooks,
114
187
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n-seo",
3
- "version": "0.4.0",
3
+ "version": "0.5.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)",
@@ -44,6 +44,7 @@
44
44
  "ingest/",
45
45
  "ops/",
46
46
  "probes/",
47
+ "profiles/",
47
48
  "public/",
48
49
  "n-seo.config.example.json",
49
50
  ".env.example",
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Aggressive",
3
+ "description": "For a large site with traffic to spare, where the cost of missing an opportunity beats the cost of a wasted afternoon. A wider striking-distance band, a lower impression floor, and a longer queue. Keeps the 28-day freeze: that one is not a preference, it is how Google reads title churn.",
4
+ "version": "1.0.0",
5
+ "operatingRules": {
6
+ "metadataChangesPerWeek": 15
7
+ },
8
+ "rules": {
9
+ "strikingDistance": { "minPosition": 4, "maxPosition": 20, "minImpressions": 5, "maxRows": 25 },
10
+ "ctrGap": { "minImpressions": 15, "belowExpectedRatio": 0.65 },
11
+ "engagement": { "minSessions": 15, "maxCards": 6 },
12
+ "trafficDrop": { "minPriorSessions": 25, "dropRatio": 0.85 },
13
+ "metadata": { "maxFindings": 10 }
14
+ }
15
+ }
@@ -0,0 +1,20 @@
1
+ {
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.",
4
+ "version": "1.0.0",
5
+ "operatingRules": {
6
+ "titleFreezeDays": 28,
7
+ "metadataChangesPerWeek": 8,
8
+ "decisionWindowDays": 90,
9
+ "historyMonths": 16
10
+ },
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 }
19
+ }
20
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "Patient",
3
+ "description": "For a site with low traffic, a 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, and a shorter queue so the list stays readable.",
4
+ "version": "1.0.0",
5
+ "operatingRules": {
6
+ "titleFreezeDays": 56,
7
+ "metadataChangesPerWeek": 3
8
+ },
9
+ "rules": {
10
+ "strikingDistance": { "minImpressions": 40, "maxRows": 6 },
11
+ "ctrGap": { "minImpressions": 100, "belowExpectedRatio": 0.4 },
12
+ "engagement": { "minSessions": 100, "maxCards": 2 },
13
+ "trafficDrop": { "minPriorSessions": 150, "dropRatio": 0.6 },
14
+ "metadata": { "maxFindings": 3 }
15
+ }
16
+ }
package/public/styles.css CHANGED
@@ -191,6 +191,10 @@ td.trunc { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-sp
191
191
  .flash { background: var(--good-soft); color: var(--good); font-weight: 600; font-size: 0.85rem; padding: 8px 14px; border-radius: 8px; }
192
192
 
193
193
  footer { max-width: 1480px; margin: 0 auto; padding: 16px 24px 40px; color: var(--muted); font-size: 0.75rem; border-top: 1px solid var(--line); }
194
+ footer .foot-ver { font-weight: 600; color: var(--ink); }
195
+ footer .foot-upd { background: var(--warn-soft); color: var(--warn); border-radius: 5px; padding: 1px 7px; font-weight: 600; }
196
+ footer .foot-upd code { background: rgba(0,0,0,0.06); color: inherit; }
197
+ footer .foot-upd a { color: inherit; text-decoration: underline; }
194
198
 
195
199
  /* ---- buttons + inline forms ---- */
196
200
  .btn {
@@ -578,7 +582,13 @@ footer { max-width: 1480px; margin: 0 auto; padding: 16px 24px 40px; color: var(
578
582
  .s-help { margin: 0; font-size: 0.75rem; color: var(--muted); }
579
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; }
580
584
  .s-field small { font-weight: 500; }
581
- .s-field input[type="text"], .s-field textarea {
585
+ .s-field input[type="text"], .prof-diff { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
586
+ .prof-diff li { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 0.76rem; }
587
+ .prof-diff .mono { flex: 1 1 100%; color: var(--muted); }
588
+ .prof-was { font-family: var(--mono); color: var(--muted); text-decoration: line-through; }
589
+ .prof-now { font-family: var(--mono); font-weight: 700; color: var(--accent); }
590
+
591
+ .s-field textarea {
582
592
  font: inherit; font-size: 0.84rem; color: var(--ink); background: var(--panel);
583
593
  border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; width: 100%; font-weight: 400;
584
594
  }
package/src/actions.ts CHANGED
@@ -4,22 +4,27 @@
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, type SiteCfg } from "./config.js";
7
+ import { SITES, config, type SiteCfg } 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
 
11
11
  export type { Action, Effort } from "./backlog.js";
12
12
 
13
- const EFFORT_WEIGHT: Record<Effort, number> = { S: 1, M: 2.5, L: 5 };
13
+ /** Read per call, not captured at import: the config is a live view, so a
14
+ * profile change or a hand edit takes effect without a restart. */
15
+ const rules = () => config().rules;
14
16
 
15
17
  /** Impact per unit of effort. Never returns NaN: one unrecognised effort or a
16
18
  * non-numeric impact would otherwise make the sort comparator return NaN and
17
19
  * leave the order of the entire queue undefined. backlog.ts coerces on load;
18
20
  * this is the second line of defence for any other producer. */
19
- export const score = (a: Action) =>
20
- (Number.isFinite(a.impact) ? a.impact : 0) / (EFFORT_WEIGHT[a.effort] ?? EFFORT_WEIGHT.M);
21
+ export const score = (a: Action) => {
22
+ const w = rules().effortWeight;
23
+ return (Number.isFinite(a.impact) ? a.impact : 0) / (w[a.effort] ?? w.M);
24
+ };
21
25
 
22
- const WINDOW_MONTHS = 3; // decision window = trailing 90 days
26
+ /** The decision window in months, from operatingRules.decisionWindowDays. */
27
+ const windowMonths = () => Math.max(1, config().operatingRules.decisionWindowDays / 30);
23
28
 
24
29
  const pathOf = (url: string): string => "/" + url.split("/").slice(3).join("/");
25
30
  const gid = (tag: string, page: string) => `gen-${tag}-${slug(page) || "root"}`;
@@ -45,7 +50,7 @@ function ctrGapActions(site: SiteCfg): Action[] {
45
50
  byPage.set(page, [...(byPage.get(page) ?? []), g]);
46
51
  }
47
52
  return [...byPage.entries()].map(([page, qs]) => {
48
- const missed = qs.reduce((s, q) => s + (q.impressions / WINDOW_MONTHS) * (q.expected - q.ctr), 0);
53
+ const missed = qs.reduce((s, q) => s + (q.impressions / windowMonths()) * (q.expected - q.ctr), 0);
49
54
  const top = qs.slice(0, 3).map((q) => `“${q.keys[0]}” (${q.impressions.toLocaleString()} imps, ${(100 * q.ctr).toFixed(1)}% CTR at pos ${q.position.toFixed(1)})`);
50
55
  const watching = watch[page];
51
56
  return {
@@ -73,7 +78,8 @@ function ctrGapActions(site: SiteCfg): Action[] {
73
78
  }
74
79
 
75
80
  function strikingActions(site: SiteCfg): Action[] {
76
- const rows = data.strikingDistance(site).slice(0, 12);
81
+ const r = rules().strikingDistance;
82
+ const rows = data.strikingDistance(site).slice(0, r.maxRows);
77
83
  if (!rows.length) return [];
78
84
  const watch = SHIPPED_WATCH();
79
85
  const pageFor = topPageForQueries(site);
@@ -84,7 +90,7 @@ function strikingActions(site: SiteCfg): Action[] {
84
90
  }
85
91
  return [...byPage.entries()].map(([page, qs]) => {
86
92
  const imps = qs.reduce((s, q) => s + q.impressions, 0);
87
- const impact = Math.round((imps / WINDOW_MONTHS) * 0.06);
93
+ const impact = Math.round((imps / windowMonths()) * r.impactPerImpression);
88
94
  const top = qs.slice(0, 3).map((q) => `“${q.keys[0]}” pos ${q.position.toFixed(1)} (${q.impressions.toLocaleString()} imps)`);
89
95
  const watching = watch[page];
90
96
  return {
@@ -127,17 +133,18 @@ function probeActions(site: SiteCfg): Action[] {
127
133
  if (!p.soft_404.real_404) out.push(mk("soft404", "Fix soft 404s", "Unknown paths must return HTTP 404, not 200 — soft 404s waste crawl budget and dilute the index."));
128
134
  if ((p.robots.ai_crawlers_blocked?.length ?? 0) > 0)
129
135
  out.push(mk("ai-block", `Unblock AI crawlers (${p.robots.ai_crawlers_blocked!.join(", ")})`, "Remove the Disallow rules — blocked AI crawlers can't cite the site."));
130
- if ((p.homepage.visible_text_bytes ?? 0) < 500 && p.homepage.status === 200)
136
+ if ((p.homepage.visible_text_bytes ?? 0) < rules().probe.minVisibleTextBytes && p.homepage.status === 200)
131
137
  out.push({ ...mk("ssr", "Server-render homepage content", "AI crawlers don't run JS — add static H1 + intro text to the shell."), impact: 15, effort: "M" });
132
138
  return out;
133
139
  }
134
140
 
135
141
  function engagementActions(site: SiteCfg): Action[] {
142
+ const e = rules().engagement;
136
143
  const watch = SHIPPED_WATCH();
137
144
  return data
138
145
  .landingPages(site)
139
- .filter((l) => l.sessions >= 30 && l.engagement < 0.25 && l.page !== "(not set)")
140
- .slice(0, 3)
146
+ .filter((l) => l.sessions >= e.minSessions && l.engagement < e.maxEngagement && l.page !== "(not set)")
147
+ .slice(0, e.maxCards)
141
148
  .map((l) => {
142
149
  const url = `https://${site.gscHost}${l.page}`;
143
150
  // GA landing paths carry no trailing slash; shippedWatch keys may have one
@@ -167,7 +174,8 @@ function engagementActions(site: SiteCfg): Action[] {
167
174
 
168
175
  function trendActions(site: SiteCfg): Action[] {
169
176
  const t = data.sessionTrend(site);
170
- if (t.prior >= 50 && t.recent < t.prior * 0.75) {
177
+ const d = rules().trafficDrop;
178
+ if (t.prior >= d.minPriorSessions && t.recent < t.prior * d.dropRatio) {
171
179
  return [{
172
180
  id: `gen-trend-${slug(site.host)}`,
173
181
  host: site.host,
@@ -199,7 +207,7 @@ function metadataActions(site: SiteCfg): Action[] {
199
207
  const audit = data.metadataAudit();
200
208
  const findings = audit?.sites[site.host] ?? audit?.sites[site.gscHost] ?? [];
201
209
  const watch = SHIPPED_WATCH();
202
- return findings.slice(0, 5).map((f) => {
210
+ return findings.slice(0, rules().metadata.maxFindings).map((f) => {
203
211
  const pagePath = f.page.replace(/^https?:\/\/[^/]+/, "") || "/";
204
212
  const monthly = Math.max(1, Math.round(f.missed_clicks_window / 3)); // audit window is 90d
205
213
  const watching = watch[f.page];
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { createRequire } from "node:module";
12
13
 
13
14
  import { execFileSync } from "node:child_process";
14
15
 
@@ -84,6 +85,86 @@ export interface Hooks {
84
85
  afterStep: Record<string, string[]>;
85
86
  }
86
87
 
88
+
89
+ /** The numbers the action engine ranks by.
90
+ *
91
+ * These were literals scattered through actions.ts and data.ts, which meant
92
+ * disagreeing with any of them required editing the engine — and then living
93
+ * with the merge every time you upgraded. A practitioner's judgement about
94
+ * what counts as striking distance is exactly the thing they should be able
95
+ * to change without forking. */
96
+ export interface Rules {
97
+ /** Impact is divided by this to rank. Higher = the effort costs more. */
98
+ effortWeight: { S: number; M: number; L: number };
99
+ strikingDistance: {
100
+ minPosition: number;
101
+ maxPosition: number;
102
+ minImpressions: number;
103
+ /** How many query rows to consider per site. */
104
+ maxRows: number;
105
+ /** Monthly clicks assumed per impression if the query moves into the top 5. */
106
+ impactPerImpression: number;
107
+ };
108
+ ctrGap: {
109
+ minImpressions: number;
110
+ /** Flagged when actual CTR is below expected × this. */
111
+ belowExpectedRatio: number;
112
+ };
113
+ engagement: { minSessions: number; maxEngagement: number; maxCards: number };
114
+ trafficDrop: {
115
+ minPriorSessions: number;
116
+ /** Flagged when recent < prior × this. */
117
+ dropRatio: number;
118
+ };
119
+ probe: { minVisibleTextBytes: number };
120
+ metadata: { maxFindings: number };
121
+ }
122
+
123
+ /** The policy a human follows, as opposed to the numbers a rule sorts by.
124
+ *
125
+ * Read by the dashboard, the daily log and the agent skills, so changing the
126
+ * freeze here changes it everywhere it is stated — rather than in six places
127
+ * that drift apart. */
128
+ export interface OperatingRules {
129
+ titleFreezeDays: number;
130
+ metadataChangesPerWeek: number;
131
+ /** Decisions ride this window; the long history is for totals only. */
132
+ decisionWindowDays: number;
133
+ historyMonths: number;
134
+ }
135
+
136
+ /** A named bundle of the two above, plus module defaults and skills.
137
+ * Resolved from `profile` in the config. See docs/PROFILES.md. */
138
+ export interface Profile {
139
+ name: string;
140
+ description?: string;
141
+ version?: string;
142
+ operatingRules?: Partial<OperatingRules>;
143
+ rules?: DeepPartial<Rules>;
144
+ modules?: Record<string, ModuleCfg>;
145
+ /** Directory of SKILL.md folders, relative to the profile, copied by `n-seo init`. */
146
+ skills?: string;
147
+ }
148
+
149
+ type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
150
+
151
+ export const DEFAULT_RULES: Rules = {
152
+ effortWeight: { S: 1, M: 2.5, L: 5 },
153
+ strikingDistance: { minPosition: 5, maxPosition: 15, minImpressions: 10, maxRows: 12, impactPerImpression: 0.06 },
154
+ ctrGap: { minImpressions: 30, belowExpectedRatio: 0.5 },
155
+ engagement: { minSessions: 30, maxEngagement: 0.25, maxCards: 3 },
156
+ trafficDrop: { minPriorSessions: 50, dropRatio: 0.75 },
157
+ probe: { minVisibleTextBytes: 500 },
158
+ metadata: { maxFindings: 5 },
159
+ };
160
+
161
+ export const DEFAULT_OPERATING_RULES: OperatingRules = {
162
+ titleFreezeDays: 28,
163
+ metadataChangesPerWeek: 8,
164
+ decisionWindowDays: 90,
165
+ historyMonths: 16,
166
+ };
167
+
87
168
  export interface Config {
88
169
  name: string;
89
170
  port: number;
@@ -102,6 +183,10 @@ export interface Config {
102
183
  /** extra Search Console properties pulled into data/gsc/<slug>/ but not shown as sites */
103
184
  gscExtraProperties: string[];
104
185
  hooks: Hooks;
186
+ /** Built-in name ("default"), a path, or an installed package. */
187
+ profile?: string;
188
+ rules: Rules;
189
+ operatingRules: OperatingRules;
105
190
  }
106
191
 
107
192
  /** Per-module defaults, so a half-written block cannot make a step guess.
@@ -133,10 +218,82 @@ function readJson<T>(p: string): T {
133
218
  }
134
219
 
135
220
  /** Fill in defaults so the rest of the app can assume the shape. */
221
+
222
+ /** Where a profile spec resolves to, or null if it does not.
223
+ *
224
+ * Three forms, in the order they are tried:
225
+ * "default" a profile shipped with the engine
226
+ * "./x" or "/x" a directory in or near the instance
227
+ * "n-seo-profile-acme" an installed package
228
+ *
229
+ * Resolution is deliberately explicit rather than clever: a profile that
230
+ * cannot be found must be an error the owner sees, never a silent fallback
231
+ * to our defaults. Someone running a client's portfolio on their agency's
232
+ * method should not discover it quietly stopped applying. */
233
+ export function resolveProfileDir(spec: string): string | null {
234
+ const builtin = path.join(ROOT, "profiles", spec);
235
+ if (!spec.includes("/") && !spec.includes("\\") && fs.existsSync(path.join(builtin, PROFILE_FILE))) return builtin;
236
+
237
+ const asPath = path.isAbsolute(spec) ? spec : path.resolve(INSTANCE, spec);
238
+ if (fs.existsSync(path.join(asPath, PROFILE_FILE))) return asPath;
239
+
240
+ try {
241
+ const req = createRequire(path.join(INSTANCE, "package.json"));
242
+ return path.dirname(req.resolve(`${spec}/${PROFILE_FILE}`));
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ export const PROFILE_FILE = "n-seo.profile.json";
249
+
250
+ /** The resolved profile, or null when none is configured. Throws when one is
251
+ * configured and cannot be found — see resolveProfileDir. */
252
+ export function loadProfile(spec: string | undefined): { profile: Profile; dir: string } | null {
253
+ if (!spec) return null;
254
+ const dir = resolveProfileDir(spec);
255
+ if (!dir) {
256
+ throw new Error(
257
+ `profile "${spec}" could not be resolved.\n` +
258
+ ` tried: a profile shipped with the engine (${path.join(ROOT, "profiles", spec)}),\n` +
259
+ ` a directory relative to the instance (${path.resolve(INSTANCE, spec)}),\n` +
260
+ ` and an installed package.\n` +
261
+ ` install it, fix the path, or remove "profile" from the config.`,
262
+ );
263
+ }
264
+ const profile = readJson<Profile>(path.join(dir, PROFILE_FILE));
265
+ return { profile: { ...profile, name: profile.name || spec }, dir };
266
+ }
267
+
268
+ /** Recursive merge for the plain-object config trees. Later wins; undefined
269
+ * never overwrites, so a profile may set one threshold without restating the
270
+ * block it lives in. */
271
+ function merge<T>(base: T, ...layers: unknown[]): T {
272
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
273
+ for (const layer of layers) {
274
+ if (!layer || typeof layer !== "object") continue;
275
+ for (const [k, v] of Object.entries(layer as Record<string, unknown>)) {
276
+ if (v === undefined) continue;
277
+ const cur = out[k];
278
+ out[k] = cur && typeof cur === "object" && !Array.isArray(cur) && v && typeof v === "object" && !Array.isArray(v)
279
+ ? merge(cur, v)
280
+ : v;
281
+ }
282
+ }
283
+ return out as T;
284
+ }
285
+
136
286
  function normalize(raw: Partial<Config>): Config {
287
+ const loaded = loadProfile(raw.profile);
288
+ const fromProfile = loaded?.profile;
289
+
137
290
  const modules: Record<string, ModuleCfg> = {};
138
- for (const m of MODULE_INFO) modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
139
- for (const [k, v] of Object.entries(raw.modules ?? {})) if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
291
+ for (const m of MODULE_INFO) {
292
+ modules[m.key] = { enabled: false, ...(MODULE_DEFAULTS[m.key] ?? {}), ...(fromProfile?.modules?.[m.key] ?? {}), ...(raw.modules?.[m.key] ?? {}) };
293
+ }
294
+ for (const [k, v] of Object.entries({ ...(fromProfile?.modules ?? {}), ...(raw.modules ?? {}) })) {
295
+ if (!modules[k]) modules[k] = { ...v, enabled: !!v?.enabled };
296
+ }
140
297
  const sites = (raw.sites ?? []).map((s) => ({
141
298
  ...s,
142
299
  label: s.label || s.host,
@@ -160,6 +317,12 @@ function normalize(raw: Partial<Config>): Config {
160
317
  modules,
161
318
  gscExtraProperties: strList(raw.gscExtraProperties),
162
319
  hooks: { beforeRun: strList(rawHooks.beforeRun), afterRun: strList(rawHooks.afterRun), afterStep },
320
+ profile: raw.profile,
321
+ // engine defaults <- profile <- this instance. The instance always wins,
322
+ // so a client can always see, and override, where they depart from the
323
+ // method they installed.
324
+ rules: merge(DEFAULT_RULES, fromProfile?.rules, raw.rules),
325
+ operatingRules: merge(DEFAULT_OPERATING_RULES, fromProfile?.operatingRules, raw.operatingRules),
163
326
  };
164
327
  }
165
328
 
package/src/data.ts CHANGED
@@ -96,10 +96,14 @@ export function gscSummary(site: SiteCfg): GscSummary {
96
96
  }
97
97
 
98
98
  /** Position 5–15 queries with meaningful impressions — the cheapest ranking wins. */
99
- export function strikingDistance(site: SiteCfg, minImpressions = 10): GscRow[] {
100
- // Recent window: decisions ride the last 90 days, not 16-month history.
99
+ export function strikingDistance(site: SiteCfg, minImpressions?: number): GscRow[] {
100
+ // Recent window: decisions ride the trailing window, not 16-month history.
101
+ // The band is a rule, so a practitioner who thinks striking distance is
102
+ // 4-12 rather than 5-15 changes a config key instead of the engine.
103
+ const r = config().rules.strikingDistance;
104
+ const min = minImpressions ?? r.minImpressions;
101
105
  return queries(site, true)
102
- .filter((r) => r.position >= 5 && r.position <= 15 && r.impressions >= minImpressions)
106
+ .filter((q) => q.position >= r.minPosition && q.position <= r.maxPosition && q.impressions >= min)
103
107
  .sort((a, b) => b.impressions - a.impressions);
104
108
  }
105
109
 
@@ -114,12 +118,14 @@ export const EXPECTED_CTR: Record<number, number> = {
114
118
  };
115
119
 
116
120
  /** Ranking well but rarely clicked — title/snippet problems. */
117
- export function ctrGaps(site: SiteCfg, minImpressions = 30): (GscRow & { expected: number })[] {
121
+ export function ctrGaps(site: SiteCfg, minImpressions?: number): (GscRow & { expected: number })[] {
122
+ const rule = config().rules.ctrGap;
123
+ const min = minImpressions ?? rule.minImpressions;
118
124
  // Recent window: a page fixed last week must stop being accused within 90 days.
119
125
  return queries(site, true)
120
126
  .flatMap((r) => {
121
127
  const expected = EXPECTED_CTR[Math.round(r.position)];
122
- return expected && r.impressions >= minImpressions && r.ctr < expected * 0.5
128
+ return expected && r.impressions >= min && r.ctr < expected * rule.belowExpectedRatio
123
129
  ? [{ ...r, expected }]
124
130
  : [];
125
131
  })
@@ -0,0 +1,34 @@
1
+ /** `n-seo profile` — what method is running, and where you depart from it. */
2
+ import { profileReport, builtinProfiles } from "./profile-report.js";
3
+
4
+ const r = profileReport();
5
+ const built = builtinProfiles();
6
+
7
+ console.log(`profile ${r.name}${r.version ? ` ${r.version}` : ""}${r.spec ? "" : " (no profile set)"}`);
8
+ if (r.spec) console.log(`spec ${r.spec}`);
9
+ if (r.dir) console.log(`from ${r.dir}`);
10
+ if (r.description) {
11
+ console.log("");
12
+ for (const line of r.description.match(/.{1,72}(\s|$)/g) ?? []) console.log(` ${line.trim()}`);
13
+ }
14
+
15
+ console.log("");
16
+ if (r.departures.length === 0) {
17
+ console.log(r.spec
18
+ ? "this instance changes nothing — it runs the profile as published"
19
+ : "this instance changes nothing — it runs the engine defaults");
20
+ } else {
21
+ console.log(`this instance overrides ${r.departures.length} value(s):\n`);
22
+ const w = Math.max(...r.departures.map((d) => d.key.length));
23
+ for (const d of r.departures) {
24
+ console.log(` ${d.key.padEnd(w)} ${JSON.stringify(d.inherited)} → ${JSON.stringify(d.instance)}`);
25
+ }
26
+ }
27
+
28
+ if (built.length) {
29
+ console.log(`\nshipped with this engine:`);
30
+ const w = Math.max(...built.map((b) => b.spec.length));
31
+ for (const b of built) console.log(` ${b.spec.padEnd(w)} ${b.name}`);
32
+ console.log(`\nset one with "profile": "<name>" in n-seo.config.json, or point it at`);
33
+ console.log(`a directory or an installed package. See docs/PROFILES.md.`);
34
+ }
@@ -0,0 +1,106 @@
1
+ /** What profile is active, and exactly where this instance departs from it.
2
+ *
3
+ * The second half is the point. Someone running a client's portfolio on an
4
+ * agency's method needs to see, at a glance, which numbers came from the
5
+ * agency and which they changed themselves — otherwise "we follow your
6
+ * model" is an unverifiable claim. */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import {
10
+ CONFIG_PATH, EXAMPLE_CONFIG_PATH, PROFILE_FILE, DEFAULT_RULES, DEFAULT_OPERATING_RULES,
11
+ ROOT, config, loadProfile, type Profile,
12
+ } from "./config.js";
13
+
14
+ export interface Departure {
15
+ /** Dotted path, e.g. "rules.strikingDistance.maxRows". */
16
+ key: string;
17
+ /** What the layer beneath says: the profile, or the engine defaults. */
18
+ inherited: unknown;
19
+ /** What this instance sets instead. */
20
+ instance: unknown;
21
+ }
22
+
23
+ export interface ProfileReport {
24
+ /** The `profile` value from the config, if any. */
25
+ spec?: string;
26
+ name: string;
27
+ description?: string;
28
+ version?: string;
29
+ dir?: string;
30
+ departures: Departure[];
31
+ }
32
+
33
+ /** Flatten a nested tree to dotted leaves, so two layers can be diffed. */
34
+ function flatten(v: unknown, prefix = "", out: Record<string, unknown> = {}) {
35
+ if (v && typeof v === "object" && !Array.isArray(v)) {
36
+ for (const [k, val] of Object.entries(v)) flatten(val, prefix ? `${prefix}.${k}` : k, out);
37
+ } else if (prefix) {
38
+ out[prefix] = v;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function mergeDeep<T>(base: T, over: unknown): T {
44
+ const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
45
+ if (over && typeof over === "object") {
46
+ for (const [k, v] of Object.entries(over as Record<string, unknown>)) {
47
+ if (v === undefined) continue;
48
+ const cur = out[k];
49
+ out[k] = cur && typeof cur === "object" && v && typeof v === "object" && !Array.isArray(v)
50
+ ? mergeDeep(cur, v)
51
+ : v;
52
+ }
53
+ }
54
+ return out as T;
55
+ }
56
+
57
+ /** The config as written, not the normalized one. Normalization has already
58
+ * merged the layers, so it can no longer say who set what. */
59
+ function rawConfig(): Record<string, unknown> {
60
+ const p = fs.existsSync(CONFIG_PATH) ? CONFIG_PATH : EXAMPLE_CONFIG_PATH;
61
+ try {
62
+ return JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ export function profileReport(): ProfileReport {
69
+ const cfg = config();
70
+ const raw = rawConfig();
71
+ const loaded = cfg.profile ? loadProfile(cfg.profile) : null;
72
+ const profile = loaded?.profile ?? null;
73
+
74
+ const inherited = flatten({
75
+ rules: mergeDeep(DEFAULT_RULES, profile?.rules),
76
+ operatingRules: mergeDeep(DEFAULT_OPERATING_RULES, profile?.operatingRules),
77
+ });
78
+ const instanceSet = flatten({ rules: raw.rules, operatingRules: raw.operatingRules });
79
+
80
+ const departures = Object.entries(instanceSet)
81
+ .filter(([key, value]) => inherited[key] !== value)
82
+ .map(([key, value]) => ({ key, inherited: inherited[key], instance: value }))
83
+ .sort((a, b) => a.key.localeCompare(b.key));
84
+
85
+ return {
86
+ spec: cfg.profile,
87
+ name: profile?.name ?? "engine defaults",
88
+ description: profile?.description,
89
+ version: profile?.version,
90
+ dir: loaded?.dir,
91
+ departures,
92
+ };
93
+ }
94
+
95
+ /** Profiles shipped with the engine. */
96
+ export function builtinProfiles(): { spec: string; name: string; description?: string }[] {
97
+ const dir = path.join(ROOT, "profiles");
98
+ if (!fs.existsSync(dir)) return [];
99
+ return fs.readdirSync(dir, { withFileTypes: true })
100
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, PROFILE_FILE)))
101
+ .map((e) => {
102
+ const p = JSON.parse(fs.readFileSync(path.join(dir, e.name, PROFILE_FILE), "utf8")) as Profile;
103
+ return { spec: e.name, name: p.name || e.name, description: p.description };
104
+ })
105
+ .sort((a, b) => a.spec.localeCompare(b.spec));
106
+ }
package/src/settings.tsx CHANGED
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import type { FC } from "hono/jsx";
8
8
  import { CONFIG_PATH, MODULE_INFO, config, saveConfig, USING_EXAMPLE_CONFIG, loadConfig, engineInfo } from "./config.js";
9
9
  import * as data from "./data.js";
10
+ import { profileReport, builtinProfiles } from "./profile-report.js";
10
11
 
11
12
  const expand = (p: string) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p);
12
13
 
@@ -32,6 +33,8 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
32
33
  const lr = data.lastRun();
33
34
  const eng = engineInfo();
34
35
  const upd = data.updateCheck();
36
+ const prof = profileReport();
37
+ const builtins = builtinProfiles();
35
38
  const hookCount = cfg.hooks.beforeRun.length + cfg.hooks.afterRun.length + Object.values(cfg.hooks.afterStep).reduce((n, l) => n + l.length, 0);
36
39
  const str = (v: unknown) => (v == null ? "" : String(v));
37
40
  return (
@@ -75,6 +78,34 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
75
78
  </p>
76
79
  )}
77
80
  </div>
81
+ <div class="s-card">
82
+ <div class="s-title">
83
+ Profile: {prof.name}{prof.version ? ` ${prof.version}` : ""}
84
+ {prof.departures.length > 0 && <span class="chip"> {prof.departures.length} override{prof.departures.length === 1 ? "" : "s"}</span>}
85
+ </div>
86
+ {prof.description && <p class="sub">{prof.description}</p>}
87
+ {!prof.spec && (
88
+ <p class="sub">
89
+ No profile set, so the engine defaults apply. Shipped with this engine:{" "}
90
+ {builtins.map((b, i) => <>{i > 0 ? ", " : ""}<code>{b.spec}</code></>)}. Set one with{" "}
91
+ <code>"profile"</code> in the config, or point it at a directory or an installed package.
92
+ </p>
93
+ )}
94
+ {prof.departures.length > 0 && (
95
+ <>
96
+ <p class="sub">Changed here, against the {prof.spec ? "profile" : "engine defaults"}:</p>
97
+ <ul class="prof-diff">
98
+ {prof.departures.map((d) => (
99
+ <li>
100
+ <span class="mono">{d.key}</span>
101
+ <span class="prof-was">{JSON.stringify(d.inherited)}</span>
102
+ <span class="prof-now">{JSON.stringify(d.instance)}</span>
103
+ </li>
104
+ ))}
105
+ </ul>
106
+ </>
107
+ )}
108
+ </div>
78
109
  <div class="s-card">
79
110
  <div class="s-title">Instance path</div>
80
111
  <div class="mono">{eng.instance}</div>
package/src/views.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Server-rendered views (hono/jsx). */
2
2
  import type { FC, PropsWithChildren } from "hono/jsx";
3
- import { SITES, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
3
+ import { SITES, ENGINE_VERSION, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
4
4
  import * as data from "./data.js";
5
5
  import { actionsFor, allActions, type Action } from "./actions.js";
6
6
  import { insights as loadInsights } from "./insights.js";
@@ -35,6 +35,7 @@ export const Layout: FC<PropsWithChildren<{ title: string; active: string }>> =
35
35
  loadConfig(); // refresh USING_EXAMPLE_CONFIG
36
36
  const cfg = config();
37
37
  const sites = cfg.sites;
38
+ const upd = data.updateCheck();
38
39
  return (
39
40
  <html lang="en">
40
41
  <head>
@@ -87,7 +88,26 @@ export const Layout: FC<PropsWithChildren<{ title: string; active: string }>> =
87
88
  )}
88
89
  <main>{children}</main>
89
90
  <footer>
90
- Local controller · refreshed by <code>ops/daily.py</code> · queue in <code>config/backlog.json</code> · nothing here posts or publishes for you
91
+ {/* Which engine produced this page. On the static mirror that is
92
+ the only place it appears — /settings is not exported, because
93
+ it shows local paths and the service-account email — so without
94
+ this a published mirror named no version at all.
95
+
96
+ The update notice is baked in at export time from the file the
97
+ daily run writes. Polling the registry from the browser instead
98
+ would put every viewer of a shared mirror onto npmjs.com on
99
+ every page view, to learn something that changes once a day. */}
100
+ <span class="foot-ver">n-seo {ENGINE_VERSION}</span>
101
+ {upd?.newer && (
102
+ <>
103
+ {" "}
104
+ <span class="foot-upd">
105
+ {upd.latest} available — upgrade with <code>n-seo upgrade</code>
106
+ {upd.notes && <> · <a href={upd.notes} target="_blank" rel="noopener noreferrer">what changed</a></>}
107
+ </span>
108
+ </>
109
+ )}
110
+ {" · "}refreshed by <code>ops/daily.py</code> · queue in <code>config/backlog.json</code> · nothing here posts or publishes for you
91
111
  </footer>
92
112
  <script dangerouslySetInnerHTML={{ __html: `document.addEventListener('click',function(e){var d=e.target.closest('dialog');if(d&&e.target===d)d.close();});` }} />
93
113
  </body>