ecoportal-api 0.10.16 → 0.10.17

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.

Potentially problematic release.


This version of ecoportal-api might be problematic. Click here for more details.

Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/.gitignore +2 -0
  3. data/.ai-assistance/bridge/.gitignore +10 -0
  4. data/.ai-assistance/bridge/CLAUDE.md +96 -0
  5. data/.ai-assistance/bridge/archive/.gitkeep +0 -0
  6. data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
  7. data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
  8. data/.ai-assistance/capabilities/assumptions-log.md +23 -0
  9. data/.ai-assistance/scripts/bridge-inbox-check.sh +119 -0
  10. data/.ai-assistance/scripts/bridge-init.sh +86 -0
  11. data/.ai-assistance/scripts/confine-to-subtree.sh +58 -0
  12. data/.ai-assistance/scripts/dirty-tree-guard.sh +96 -0
  13. data/.ai-assistance/scripts/distill_procedural.py +602 -0
  14. data/.ai-assistance/scripts/log-mcp-access.sh +24 -0
  15. data/.ai-assistance/scripts/log-skill-usage.sh +79 -0
  16. data/.ai-assistance/scripts/log_mcp_access.py +158 -0
  17. data/.ai-assistance/scripts/observe-session.sh +13 -0
  18. data/.ai-assistance/scripts/observe_session.py +287 -0
  19. data/.ai-assistance/scripts/protect-host-paths.sh +135 -0
  20. data/.ai-assistance/scripts/scrub.py +1149 -0
  21. data/.ai-assistance/scripts/scrub.py.sha256 +6 -0
  22. data/.ai-assistance/scripts/surface-procedural.sh +9 -0
  23. data/.ai-assistance/scripts/surface_procedural.py +101 -0
  24. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +519 -0
  25. data/.ai-assistance/skills/project-self-docs/SKILL.md +259 -0
  26. data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
  27. data/.ai-assistance/standards-version.json +12 -0
  28. data/.ai-assistance/version.json +8 -0
  29. data/.claude/.gitignore +2 -0
  30. data/.claude/settings.json +128 -0
  31. data/CHANGELOG.md +8 -5
  32. data/CLAUDE.md +95 -71
  33. data/docs/self-docs/ARCHITECTURE.md +145 -0
  34. data/docs/self-docs/CHANGES.jsonl +7 -0
  35. data/docs/self-docs/COMPLIANCE.md +66 -0
  36. data/docs/self-docs/CONVENTIONS.md +74 -0
  37. data/docs/self-docs/INTEGRATIONS.md +62 -0
  38. data/docs/self-docs/OPERATIONS.md +64 -0
  39. data/docs/self-docs/OVERVIEW.md +61 -0
  40. data/docs/self-docs/STATUS.md +71 -0
  41. data/docs/self-docs/self-docs-index.json +51 -0
  42. data/docs/worklog.md +48 -0
  43. data/lib/ecoportal/api/common/client/with_retry.rb +6 -0
  44. data/lib/ecoportal/api/version.rb +1 -1
  45. metadata +40 -1
@@ -0,0 +1,259 @@
1
+ ---
2
+ name: project-self-docs
3
+ category: knowledge
4
+ version: 0.4.0
5
+ description: >
6
+ Generate and maintain a project's own committed self-documentation set (a stable-schema
7
+ OVERVIEW/ARCHITECTURE/CONVENTIONS/INTEGRATIONS/STATUS/COMPLIANCE/OPERATIONS) plus a machine-readable index and
8
+ changelog, so the project can later feed a central coordination hub. A thin orchestrator:
9
+ it reuses ai-discovery/code-specs/discovery-manifest findings and a deterministic scanner,
10
+ and never invents a documentation format. Invoke to create, refresh, or verify self-docs.
11
+ triggers:
12
+ - self docs
13
+ - self-documenting
14
+ - project-self-docs
15
+ - generate project docs
16
+ - refresh self docs
17
+ - self document this project
18
+ - hub docs
19
+ applicable_to:
20
+ - any
21
+ ---
22
+
23
+ # project-self-docs
24
+
25
+ ## Role
26
+
27
+ You maintain a project's SELF-DOCUMENTATION set: a small, stable set of committed markdown docs
28
+ that describe what the project is and how it is built, plus a machine-readable index and changelog
29
+ that a central coordination hub can consume. You are a THIN ORCHESTRATOR -- you compose facts that
30
+ `ai-discovery`, `code-specs`, and the deterministic scanner already produce; you do NOT re-analyse
31
+ the codebase from scratch, and you do NOT invent a new documentation format. Everything you write
32
+ is idempotent and re-runnable: running twice on an unchanged repo changes nothing.
33
+
34
+ This capability is the per-project half of the synergy hub (ADR-015, Section 8; and ADR-014). It exists
35
+ to detune the fleet by making reuse possible -- a project that documents itself against a shared
36
+ schema is a project others can find and build on instead of duplicating.
37
+
38
+ ---
39
+
40
+ ## On invocation
41
+
42
+ Before any other output, do both of the following:
43
+
44
+ 1. Print to the user:
45
+ `[skill: project-self-docs] <one-line description of what you are about to do>`
46
+ For example:
47
+ - `[skill: project-self-docs] scanning repo and reporting self-doc status`
48
+ - `[skill: project-self-docs] refreshing STATUS.md and ARCHITECTURE.md from current state`
49
+ - `[skill: project-self-docs] creating the self-doc set for the first time`
50
+
51
+ 2. Append a usage record to `.ai-assistance/local/kpi/usage-<YYYY-WNN>.jsonl`
52
+ (ISO week format: `YYYY-WNN`, e.g. `2026-W24`):
53
+ ```json
54
+ {"component": "skill/project-self-docs", "action": "invoked", "detail": "<same one-liner>", "ts": "<ISO timestamp>", "session_id": "<if known from token-budget session>"}
55
+ ```
56
+ Use `action` values: `invoked`, `completed`, `skipped`. If the file or directory does not
57
+ exist, create it. If writing fails, continue silently.
58
+
59
+ ---
60
+
61
+ ## Before answering -- run the deterministic scan FIRST
62
+
63
+ The deterministic scanner does the zero-LLM half. ALWAYS run it before writing any prose, so you
64
+ spend interactive tokens only on the semantic gaps it cannot fill:
65
+
66
+ ```bash
67
+ python .ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py --json
68
+ ```
69
+
70
+ (From ep-ai-standards itself, the script lives at
71
+ `skills-library/project-self-docs/scripts/self_docs_scan.py`.)
72
+
73
+ The observation tells you deterministically: repo type, real repo name (from the remote), git
74
+ head/branch, top-level structure, key config + CI files, activity clusters (hot paths), which
75
+ existing inputs you can REUSE (`ai-discovery` `repo-context.md`/`conventions.md`, `code-specs`
76
+ under `.ai-assistance/code/`), and which self-docs already exist with their current content hash.
77
+
78
+ **Reuse before you write.** If `repo-context.md`, `conventions.md`, or code-specs exist, compose
79
+ the self-docs from them -- do not re-derive the same facts. If they do NOT exist, propose running
80
+ `ai-discovery` (first-time) or `code-specs` (per-area) first, rather than duplicating their work
81
+ here.
82
+
83
+ ---
84
+
85
+ ## The self-doc set (stable schema -- do not change filenames)
86
+
87
+ Copy the templates from `templates/project-self-docs/` into the project's self-docs directory
88
+ (default `docs/self-docs/`; configurable). The set is intentionally small and fixed:
89
+
90
+ | File | Covers |
91
+ |---|---|
92
+ | `OVERVIEW.md` | What the project is, who it serves, current priority (one screen) |
93
+ | `ARCHITECTURE.md` | Top-level structure, key components, how they fit, entry points |
94
+ | `CONVENTIONS.md` | Coding style, branch naming, commit style, test conventions |
95
+ | `INTEGRATIONS.md` | External services + data + permissions, upstream/downstream repos, dependencies |
96
+ | `STATUS.md` | Active work, migration state, known risks, open questions |
97
+ | `COMPLIANCE.md` | ISO-27001/audit, data classes + PII, third-party vendors, AI content, leak controls |
98
+ | `OPERATIONS.md` | Infrastructure, public exposure + Cloudflare, DevOps audit runbook, usage KPIs/ROI |
99
+
100
+ The set is the ROVO audit/compliance/discovery surface once mirrored into the EP Projects Register
101
+ (`docs/strategy/self-docs-audit-schema.md`). Schema is `1.1`. Each file carries YAML frontmatter
102
+ (`schema_version`, `repo`, `doc`, `last_generated`,
103
+ `source_head`, `review_status`) so the hub can parse it and so a human review gate is explicit.
104
+ Do NOT add, rename, or drop files without bumping the template `schema_version` and the scanner's
105
+ `SELF_DOC_SET` together (they are checked as a pair).
106
+
107
+ ### ARCHITECTURE.md diagrams (mermaid) -- the "something visual that really adds up"
108
+
109
+ `ARCHITECTURE.md` carries a `## Diagrams` section (see
110
+ `templates/project-self-docs/ARCHITECTURE.md.template`) instructing the in-repo session to render
111
+ **2-4 mermaid diagrams**, chosen from:
112
+
113
+ 1. **Component / layer diagram with trust boundaries** -- e.g. a PII-scrubber layer, a public/
114
+ private network split, an authenticated/unauthenticated boundary.
115
+ 2. **CI/CD pipeline graph** -- derived from the repo's real CI config (e.g. `.gitlab-ci.yml`,
116
+ `.github/workflows/*.yml`): one node per real job/stage, edges from real `needs`/stage order.
117
+ 3. **Data-flow diagram for privacy-relevant paths** -- source -> scrub/transform -> egress, naming
118
+ the real modules/scripts at each hop (only when the repo has such a path; see COMPLIANCE.md).
119
+ 4. **Deployment topology (optional)** -- only when infra-as-code or deployment config exists
120
+ (CDK/Terraform/Dockerfile/docker-compose).
121
+
122
+ Hard rules for whoever fills this section (the in-repo session, never an ep-ai-standards session
123
+ reaching into another repo -- same actuation boundary as the rest of this skill):
124
+
125
+ - **Derive from real files only, and name them.** Every diagram's caption must cite the concrete
126
+ file(s)/dir(s) it was drawn from (e.g. "sources: `.gitlab-ci.yml`, `lib/scrub/`"). A diagram with
127
+ no citable source is not written.
128
+ - **Never invent a component, job, or hop.** If a category does not apply (no CI config, no
129
+ privacy-relevant data path, no deployment manifest), omit that diagram rather than fabricate one
130
+ to hit the 2-4 count. 2 real diagrams beat 4 where two are invented.
131
+ - **Conservative mermaid syntax** -- `flowchart TD` or `graph LR` only (no advanced/experimental
132
+ mermaid features), for the widest renderer compatibility (local preview, GitLab, and the
133
+ Confluence path below).
134
+ - This is additive content within the existing `ARCHITECTURE.md` file -- it does not add a new
135
+ self-doc, so it does not touch `schema_version` or the scanner's `SELF_DOC_SET`.
136
+
137
+ **Confluence rendering: PLANNED, not live.** Mermaid can render in Confluence via a Forge ADF
138
+ extension (a converter emits the ADF node from the mermaid source; this needs a round-trip
139
+ render test before anyone relies on it). No such converter script exists yet under `scripts/` in
140
+ this repo -- until one lands and is referenced here by name, treat Confluence rendering of these
141
+ diagrams as **PLANNED**, not verified. In the interim the diagrams render wherever the committed
142
+ markdown is viewed directly (GitLab file view, local preview, editors with mermaid support).
143
+
144
+ ### Committed-by-design (the DEFAULT) + declared-signal opt-out
145
+
146
+ > Policy per OD-008 (PROVISIONAL owner-ruling 2026-07-10, option C -- pending Oscar's
147
+ > confirmation at merge; see `docs/governance/open-decisions.md` OD-008). Not live until the
148
+ > ruling MR is merged.
149
+
150
+ These docs are **COMMITTED (not gitignored) by design** -- they are the shareable artefact
151
+ (unlike the gitignored `.ai-assistance/local/` context that `ai-discovery` writes) and the
152
+ substrate the EP Projects Register / coordination hub aggregates. Consumer maintainers:
153
+ committing generated output is intentional here, because the docs are only discoverable
154
+ across the fleet if they are IN git. ep-ai-standards tracks its own `docs/self-docs/` for the
155
+ same reason.
156
+
157
+ **Committed is the DEFAULT.** A maintainer who prefers gitignored + regenerate-on-demand may
158
+ do so, but ONLY **explicitly, via a declared signal** -- never silently. Silent divergence is
159
+ the one thing the policy forbids, because the hub then cannot tell whether to expect committed
160
+ docs or a regen step. To opt out of committing:
161
+
162
+ 1. Record a declared marker in the repo's self-docs `STATUS.md` frontmatter:
163
+ `self_docs_mode: gitignored-regenerate` (default when absent is `committed`), AND
164
+ 2. Document the regen path (how/when the set is regenerated for the Register -- e.g. a CI
165
+ publish step), AND
166
+ 3. Gitignore `docs/self-docs/` in that repo.
167
+
168
+ With the marker present, the divergence is DECLARED and discoverable; without it, committed is
169
+ assumed and a gitignored set is treated as drift.
170
+
171
+ ---
172
+
173
+ ## Workflow
174
+
175
+ 1. **Scan.** Run `self_docs_scan.py --json`. Read the observation.
176
+ 2. **Reuse.** Pull facts from the existing inputs it lists. Only genuinely-missing semantic content
177
+ is yours to write.
178
+ 3. **Fill / refresh.** For each self-doc: if missing, copy the template and fill it; if present,
179
+ update only the sections whose underlying facts changed (structure, hot paths, status). Keep
180
+ `review_status: draft` on anything a human has not confirmed. For `ARCHITECTURE.md`, also fill
181
+ or refresh the `## Diagrams` section per "ARCHITECTURE.md diagrams (mermaid)" above -- only
182
+ when the underlying real files (CI config, scrub modules, deployment manifests) changed enough
183
+ to make an existing diagram stale.
184
+ 4. **Batch heavy semantic work to Gemini.** For a large or unfamiliar repo, draft
185
+ ARCHITECTURE/OVERVIEW via the `gemini-assist` skill (no-training tier) rather than on
186
+ interactive Claude tokens -- per the token-frugality rule. Verify Gemini's output against the
187
+ scanner facts before writing.
188
+ 5. **Write the index + changelog (deterministic).** Run:
189
+ ```bash
190
+ python .ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py --write
191
+ ```
192
+ This rewrites `docs/self-docs/self-docs-index.json` (the machine surface the hub reads) and
193
+ APPENDS one line per changed doc to `docs/self-docs/CHANGES.jsonl`. A no-op re-run appends
194
+ nothing -- this is what makes the capability idempotent.
195
+ 6. **Leave a consumer-repo breadcrumb (OD-007).** When this run generated or committed any
196
+ self-doc into the repo, write a minimal in-repo trail so a developer (or a second agent) on
197
+ the same tree can tell where the change came from. Add ONE line to the repo's
198
+ `docs/worklog.md` (create it if absent) AND, if generating the set for the first time, a
199
+ short note in the self-docs `STATUS.md`. Format:
200
+ ```
201
+ generated by project-self-docs v<skill-version> per migration <NNNN-or-none>, review_status: draft, run <YYYY-MM-DD>
202
+ ```
203
+ This is the consumer-side analogue of the ecoportal working-tree watermark. A pure no-op
204
+ re-run (nothing changed) writes no breadcrumb -- keep it idempotent. See
205
+ `docs/governance/open-decisions.md` OD-007 (PROVISIONAL ruling 2026-07-10, pending merge).
206
+ 7. **Human review before any egress.** Leave publishing to a human-reviewed step (see below).
207
+
208
+ ---
209
+
210
+ ## Publish / egress -- CONFIGURABLE, default local-only (OPEN DECISION)
211
+
212
+ The Confluence publish destination is an OPEN DECISION being made separately (existing EPAI space
213
+ vs a new dedicated hub space vs the private EP-AI-MGMT space). Therefore:
214
+
215
+ - **Default: write to the repo/local filesystem ONLY.** This skill does not push to Confluence.
216
+ - The publish target is a PARAMETER, not a hardcoded value. When the decision is made, the egress
217
+ step becomes a call to the existing seed pipeline (`scripts/seed-epai-project-docs.py` pattern)
218
+ pointed at the chosen space, gated by human review.
219
+ - <!-- TODO(hub-target): wire egress to the chosen Confluence space once the publish-target
220
+ decision lands. Until then, self-docs live in docs/self-docs/ and are consumed from git. -->
221
+
222
+ Never emit `r` (a result) straight to a shared surface: a human review gate sits between the
223
+ generated self-docs and any hub publish. This is a damping control -- keep it.
224
+
225
+ ---
226
+
227
+ ## Actuation
228
+
229
+ - **Actor:** a developer (or `ai-discovery`/`project-cycle` calling this skill) IN THE REPO THAT
230
+ DOCUMENTS ITSELF, never an unattended agent -- and NEVER an ep-ai-standards session reaching into
231
+ another repo to generate that repo's self-docs. ep-ai-standards AUTHORS + DEPLOYS this skill and
232
+ AGGREGATES/publishes results; each repo RUNS it in its own session. (Running it on ep-ai-standards
233
+ ITSELF is fine -- that is why `docs/self-docs/` exists here.)
234
+ - **Trigger / clock:** on demand; recommended at project cycle-end (`project-cycle`) and after a
235
+ structural change. There is no autonomous fleet-wide trigger yet -- that depends on the unbuilt
236
+ ADR-014 upkeep loop; note this rather than inventing an actuator.
237
+ - **Coverage on neglect:** if self-docs are never refreshed, the scanner's `content_hash` +
238
+ `source_head` in the index let the hub (and `project-review`) flag them as stale. The doc is
239
+ never silently trusted; staleness is detectable.
240
+ - **Write authority:** writes under the self-docs directory (default `docs/self-docs/`), the KPI
241
+ usage line, and -- per OD-007 -- a single breadcrumb line in the repo's `docs/worklog.md` (the
242
+ one deliberate write outside the self-docs dir, and only when a doc was generated/committed on
243
+ this run). It does not touch source code, `.ai-assistance/local/`, git remotes, or any network
244
+ target.
245
+
246
+ ---
247
+
248
+ ## What you do not do
249
+
250
+ - Do not invent a documentation format -- use the fixed self-doc set and templates.
251
+ - Do not re-analyse the codebase from scratch -- reuse `ai-discovery`, `code-specs`, and the
252
+ scanner; propose running those skills if their inputs are missing.
253
+ - Do not publish to Confluence or any network target -- egress is a separate, human-gated,
254
+ as-yet-undecided step.
255
+ - Do not write outside `docs/self-docs/` (or the configured self-docs dir), EXCEPT the single
256
+ OD-007 breadcrumb line in the repo's `docs/worklog.md` and the KPI usage line.
257
+ - Do not run heavy semantic drafting on interactive Claude tokens for large repos -- batch to
258
+ Gemini (no-training tier).
259
+ - Do not mark a doc `review_status: reviewed` -- only a human does that.
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ self_docs_scan.py -- Project self-docs OBSERVE stage (deterministic, zero-LLM core).
4
+
5
+ Skill (semantic refresh + Act): skills-library/project-self-docs/SKILL.md.
6
+
7
+ Runs FROM A TARGET REPO (the repo documenting itself), not from ep-ai-standards. It does the
8
+ DETERMINISTIC half of self-documentation so no interactive Claude tokens are spent on facts the
9
+ filesystem already knows:
10
+ - detect the repo type using the same signals as skills-library/discovery-manifest.yaml
11
+ - collect a structural fingerprint (top-level dirs, key config files, git head/branch/remote,
12
+ commit-activity clusters, existing .ai-assistance/code specs, CI files, doc files)
13
+ - compute a stable content hash per self-doc so refreshes are idempotent and the changelog
14
+ only records REAL changes
15
+ - emit a structured JSON "observation" the skill consumes to fill the self-doc set
16
+
17
+ It does NOT write project prose, summarise purpose, or judge architecture -- that semantic work is
18
+ the LLM skill's job (batched to Gemini per the token-frugality rule), gated by human review before
19
+ any egress. This scanner is the thermometer; the skill is the (human-gated) actuator.
20
+
21
+ HARD CONSTRAINTS:
22
+ - READ-ONLY by default. It never writes into the target repo except the self-doc index/changelog
23
+ under --write, and even then only to the self-docs directory (default docs/self-docs/).
24
+ - NO absolute paths are emitted into any committed artefact: paths are repo-relative.
25
+ - NO Confluence / network egress. Publish target is UNDECIDED (see SKILL.md); this script only
26
+ ever touches the local filesystem.
27
+
28
+ Usage:
29
+ python self_docs_scan.py # human summary of the observation (target = cwd)
30
+ python self_docs_scan.py --json # structured observation to stdout
31
+ python self_docs_scan.py --target <path> # observe a different LOCAL repo root (convenience
32
+ # only -- NEVER to generate another repo's self-docs
33
+ # from here; run the skill in that repo's own session)
34
+ python self_docs_scan.py --out docs/self-docs # self-docs dir (default docs/self-docs)
35
+ python self_docs_scan.py --write # (re)write the machine index + changelog only
36
+ """
37
+ import argparse
38
+ import hashlib
39
+ import json
40
+ import os
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ from datetime import datetime, timezone
45
+
46
+ SCHEMA_VERSION = "1.1"
47
+
48
+ # Self-doc set: stable filenames the skill fills in. Keeping this list here (not only in the
49
+ # template) lets the deterministic scanner report which docs already exist and which are missing,
50
+ # and lets the index/changelog be generated without the LLM.
51
+ SELF_DOC_SET = [
52
+ ("OVERVIEW.md", "What the project is, who it serves, current priority, discovery & access"),
53
+ ("ARCHITECTURE.md", "Top-level structure, key components, how they fit"),
54
+ ("CONVENTIONS.md", "Coding style, branch naming, commit style, test conventions"),
55
+ ("INTEGRATIONS.md", "External services + data + permissions, upstream/downstream repos"),
56
+ ("STATUS.md", "Active work, migration state, known risks, open questions"),
57
+ ("COMPLIANCE.md", "ISO-27001/audit, data classes + PII, vendors, AI content, leak controls"),
58
+ ("OPERATIONS.md", "Infrastructure, public exposure + Cloudflare, DevOps audit, usage KPIs"),
59
+ ]
60
+
61
+ # Detection signals mirror skills-library/discovery-manifest.yaml. Order matters: first match wins.
62
+ # (file, ...) => all must exist; file_any => any of the globs; not_file => must be absent.
63
+ REPO_TYPE_RULES = [
64
+ ("aws-cdk", {"file": ["cdk.json"]}),
65
+ ("browser-extension", {"file": ["manifest.json"], "file_any": ["package.json", "Gruntfile.js", "webpack.config.js"]}),
66
+ ("ruby-gem", {"file_any": ["*.gemspec"]}),
67
+ ("rails-app", {"file": ["config/application.rb"]}),
68
+ ("ruby-scripts", {"file": ["Gemfile"], "not_file": ["*.gemspec", "config/application.rb"]}),
69
+ ("typescript-lib", {"file": ["package.json"], "not_file": ["cdk.json", "manifest.json"]}),
70
+ ]
71
+
72
+ KEY_CONFIG_FILES = [
73
+ "package.json", "Gemfile", "cdk.json", "manifest.json", "go.mod", "Cargo.toml",
74
+ "pyproject.toml", "requirements.txt", "tsconfig.json", ".eslintrc", ".eslintrc.json",
75
+ ".eslintrc.js", ".prettierrc", ".rubocop.yml", ".editorconfig",
76
+ ]
77
+ CI_FILES = [".gitlab-ci.yml", "Jenkinsfile", "Makefile"]
78
+
79
+
80
+ def _iso_now():
81
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
82
+
83
+
84
+ def _exists(root, rel):
85
+ return os.path.exists(os.path.join(root, rel))
86
+
87
+
88
+ def _glob_any(root, patterns):
89
+ import fnmatch
90
+ try:
91
+ top = os.listdir(root)
92
+ except OSError:
93
+ return False
94
+ for pat in patterns:
95
+ if any(fnmatch.fnmatch(name, pat) for name in top):
96
+ return True
97
+ return False
98
+
99
+
100
+ def detect_repo_type(root):
101
+ for type_name, rule in REPO_TYPE_RULES:
102
+ ok = True
103
+ for f in rule.get("file", []):
104
+ if not _exists(root, f):
105
+ ok = False
106
+ break
107
+ if ok and rule.get("file_any"):
108
+ ok = _glob_any(root, rule["file_any"])
109
+ if ok and rule.get("not_file"):
110
+ for f in rule["not_file"]:
111
+ if "*" in f:
112
+ if _glob_any(root, [f]):
113
+ ok = False
114
+ break
115
+ elif _exists(root, f):
116
+ ok = False
117
+ break
118
+ if ok:
119
+ return type_name
120
+ return "unknown"
121
+
122
+
123
+ def _git(root, args):
124
+ try:
125
+ out = subprocess.run(
126
+ ["git", "-C", root] + args,
127
+ capture_output=True, text=True, timeout=15,
128
+ )
129
+ return out.stdout.strip() if out.returncode == 0 else ""
130
+ except (OSError, subprocess.SubprocessError):
131
+ return ""
132
+
133
+
134
+ def git_facts(root):
135
+ branch = _git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
136
+ head = _git(root, ["rev-parse", "--short", "HEAD"])
137
+ remote = _git(root, ["config", "--get", "remote.origin.url"])
138
+ # Real repo name from the remote, never a local folder name that may be renamed.
139
+ repo_name = ""
140
+ if remote and "/" in remote:
141
+ repo_name = re.sub(r"\.git$", "", remote.rstrip("/").split("/")[-1])
142
+ # Activity clusters: which top-level dirs churn most in the last 50 commits.
143
+ log = _git(root, ["log", "-50", "--name-only", "--pretty=format:"])
144
+ clusters = {}
145
+ for line in log.splitlines():
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+ top = line.split("/")[0] if "/" in line else line
150
+ clusters[top] = clusters.get(top, 0) + 1
151
+ top_clusters = sorted(clusters.items(), key=lambda kv: kv[1], reverse=True)[:6]
152
+ return {
153
+ "branch": branch,
154
+ "head": head,
155
+ "repo_name": repo_name,
156
+ "has_remote": bool(remote),
157
+ "activity_clusters": [{"path": k, "touches": v} for k, v in top_clusters],
158
+ }
159
+
160
+
161
+ def structure(root):
162
+ dirs, files = [], []
163
+ try:
164
+ for name in sorted(os.listdir(root)):
165
+ if name.startswith(".git"):
166
+ continue
167
+ full = os.path.join(root, name)
168
+ if os.path.isdir(full):
169
+ dirs.append(name)
170
+ else:
171
+ files.append(name)
172
+ except OSError:
173
+ pass
174
+ return {
175
+ "top_level_dirs": dirs,
176
+ "key_config_files": [f for f in KEY_CONFIG_FILES if _exists(root, f)],
177
+ "ci_files": [f for f in CI_FILES if _exists(root, f)],
178
+ "has_readme": _exists(root, "README.md") or _exists(root, "README"),
179
+ "has_changelog": _exists(root, "CHANGELOG.md"),
180
+ }
181
+
182
+
183
+ def existing_context(root):
184
+ """Reusable inputs that already exist -- the skill composes from these, not from scratch."""
185
+ ctx = {"code_specs": [], "repo_context": None, "conventions": None}
186
+ code_dir = os.path.join(root, ".ai-assistance", "code")
187
+ if os.path.isdir(code_dir):
188
+ for dirpath, _, filenames in os.walk(code_dir):
189
+ for fn in filenames:
190
+ if fn.endswith(".md"):
191
+ rel = os.path.relpath(os.path.join(dirpath, fn), root).replace("\\", "/")
192
+ ctx["code_specs"].append(rel)
193
+ ctx["code_specs"].sort()
194
+ local = os.path.join(root, ".ai-assistance", "local")
195
+ if os.path.isfile(os.path.join(local, "repo-context.md")):
196
+ ctx["repo_context"] = ".ai-assistance/local/repo-context.md"
197
+ if os.path.isfile(os.path.join(local, "conventions.md")):
198
+ ctx["conventions"] = ".ai-assistance/local/conventions.md"
199
+ return ctx
200
+
201
+
202
+ def _content_hash(path):
203
+ """Hash the self-doc BODY (below the frontmatter) so index/changelog record real changes,
204
+ not just a re-run timestamp. Missing file -> None."""
205
+ if not os.path.isfile(path):
206
+ return None
207
+ with open(path, encoding="utf-8", errors="replace") as fh:
208
+ text = fh.read()
209
+ body = text
210
+ if text.startswith("---"):
211
+ end = text.find("\n---", 3)
212
+ if end != -1:
213
+ body = text[end + 4:]
214
+ # Normalise line endings + trailing whitespace so cross-platform re-runs are stable.
215
+ norm = "\n".join(line.rstrip() for line in body.replace("\r\n", "\n").split("\n")).strip()
216
+ return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
217
+
218
+
219
+ def self_doc_status(root, out_rel):
220
+ out_dir = os.path.join(root, out_rel)
221
+ docs = []
222
+ for fname, purpose in SELF_DOC_SET:
223
+ path = os.path.join(out_dir, fname)
224
+ docs.append({
225
+ "file": f"{out_rel}/{fname}".replace("\\", "/"),
226
+ "purpose": purpose,
227
+ "exists": os.path.isfile(path),
228
+ "content_hash": _content_hash(path),
229
+ })
230
+ return docs
231
+
232
+
233
+ def observe(root, out_rel):
234
+ return {
235
+ "schema_version": SCHEMA_VERSION,
236
+ "observed_at": _iso_now(),
237
+ "repo_type": detect_repo_type(root),
238
+ "git": git_facts(root),
239
+ "structure": structure(root),
240
+ "existing_context": existing_context(root),
241
+ "self_doc_set": self_doc_status(root, out_rel),
242
+ "out_dir": out_rel.replace("\\", "/"),
243
+ }
244
+
245
+
246
+ # == index + changelog (deterministic writers) ======================================
247
+
248
+ def _read_json(path, default):
249
+ if not os.path.isfile(path):
250
+ return default
251
+ try:
252
+ with open(path, encoding="utf-8") as fh:
253
+ return json.load(fh)
254
+ except (OSError, ValueError):
255
+ return default
256
+
257
+
258
+ def write_index_and_changelog(root, out_rel, obs):
259
+ """Rewrite the machine-readable index and append changelog deltas -- deterministically.
260
+
261
+ The index (self-docs-index.json) is the machine surface a central hub consumes: schema
262
+ version, repo identity, and a hash per doc. The changelog (CHANGES.jsonl) appends one line
263
+ per doc whose content hash CHANGED since the previous index -- so the hub sees real deltas,
264
+ and a no-op re-run appends nothing (idempotent)."""
265
+ out_dir = os.path.join(root, out_rel)
266
+ os.makedirs(out_dir, exist_ok=True)
267
+ index_path = os.path.join(out_dir, "self-docs-index.json")
268
+ changes_path = os.path.join(out_dir, "CHANGES.jsonl")
269
+
270
+ prev = _read_json(index_path, {})
271
+ prev_hashes = {d["file"]: d.get("content_hash") for d in prev.get("docs", [])}
272
+
273
+ docs = obs["self_doc_set"]
274
+ now = obs["observed_at"]
275
+ deltas = []
276
+ for d in docs:
277
+ new_h = d["content_hash"]
278
+ old_h = prev_hashes.get(d["file"])
279
+ if new_h != old_h:
280
+ deltas.append({
281
+ "ts": now,
282
+ "file": d["file"],
283
+ "change": ("created" if old_h is None and new_h is not None
284
+ else "removed" if new_h is None
285
+ else "updated"),
286
+ "from_hash": old_h,
287
+ "to_hash": new_h,
288
+ })
289
+
290
+ index = {
291
+ "schema_version": SCHEMA_VERSION,
292
+ "generated_at": now,
293
+ "repo_name": obs["git"].get("repo_name") or "",
294
+ "repo_type": obs["repo_type"],
295
+ "head": obs["git"].get("head", ""),
296
+ "docs": [
297
+ {"file": d["file"], "purpose": d["purpose"],
298
+ "exists": d["exists"], "content_hash": d["content_hash"]}
299
+ for d in docs
300
+ ],
301
+ }
302
+ with open(index_path, "w", encoding="utf-8") as fh:
303
+ json.dump(index, fh, indent=2)
304
+ fh.write("\n")
305
+
306
+ if deltas:
307
+ with open(changes_path, "a", encoding="utf-8") as fh:
308
+ for delta in deltas:
309
+ fh.write(json.dumps(delta) + "\n")
310
+
311
+ return {"index": f"{out_rel}/self-docs-index.json".replace("\\", "/"),
312
+ "changelog": f"{out_rel}/CHANGES.jsonl".replace("\\", "/"),
313
+ "deltas_appended": len(deltas)}
314
+
315
+
316
+ def _human(obs):
317
+ g = obs["git"]
318
+ print(f"self-docs observation (schema {obs['schema_version']})")
319
+ print(f" repo_type : {obs['repo_type']}")
320
+ print(f" repo_name : {g.get('repo_name') or '(no remote)'} @ {g.get('head') or '?'} "
321
+ f"on {g.get('branch') or '?'}")
322
+ print(f" out_dir : {obs['out_dir']}")
323
+ print(" top-level dirs:", ", ".join(obs["structure"]["top_level_dirs"]) or "(none)")
324
+ if obs["structure"]["key_config_files"]:
325
+ print(" config :", ", ".join(obs["structure"]["key_config_files"]))
326
+ if g.get("activity_clusters"):
327
+ hot = ", ".join(f"{c['path']}({c['touches']})" for c in g["activity_clusters"])
328
+ print(" hot paths :", hot)
329
+ ec = obs["existing_context"]
330
+ reuse = []
331
+ if ec["repo_context"]:
332
+ reuse.append("ai-discovery repo-context.md")
333
+ if ec["conventions"]:
334
+ reuse.append("ai-discovery conventions.md")
335
+ if ec["code_specs"]:
336
+ reuse.append(f"{len(ec['code_specs'])} code-specs")
337
+ print(" reuse from:", ", ".join(reuse) or "(none -- run ai-discovery/code-specs first)")
338
+ print(" self-doc set:")
339
+ for d in obs["self_doc_set"]:
340
+ state = "present" if d["exists"] else "MISSING"
341
+ print(f" [{state:7}] {d['file']} ({d['content_hash'] or '-'})")
342
+
343
+
344
+ def main():
345
+ ap = argparse.ArgumentParser(description="Deterministic project self-docs observation")
346
+ ap.add_argument("--target", default=".",
347
+ help="LOCAL repo root to observe (default: cwd). Convenience only -- do NOT use to "
348
+ "generate ANOTHER repo's self-docs from here; run the skill in that repo's own "
349
+ "session (self-docs are owned + generated by the repo that documents itself).")
350
+ ap.add_argument("--out", default="docs/self-docs", help="self-docs dir (default: docs/self-docs)")
351
+ ap.add_argument("--json", action="store_true", help="emit the structured observation")
352
+ ap.add_argument("--write", action="store_true",
353
+ help="(re)write the machine index + changelog deltas only")
354
+ args = ap.parse_args()
355
+
356
+ root = os.path.abspath(args.target)
357
+ if not os.path.isdir(root):
358
+ print(f"[self-docs] target not a directory: {root}", file=sys.stderr)
359
+ return 2
360
+
361
+ obs = observe(root, args.out)
362
+
363
+ if args.write:
364
+ result = write_index_and_changelog(root, args.out, obs)
365
+ obs["_write_result"] = result
366
+
367
+ if args.json:
368
+ print(json.dumps(obs, indent=2))
369
+ else:
370
+ _human(obs)
371
+ if args.write:
372
+ r = obs["_write_result"]
373
+ print(f" wrote index -> {r['index']}; changelog deltas appended: {r['deltas_appended']}")
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ {
2
+ "ep-ai-standards-version": "1.14.0",
3
+ "applied-at": "2026-07-30",
4
+ "project-type": [
5
+ "ruby"
6
+ ],
7
+ "deferred": [],
8
+ "installed-components": {
9
+ "skills/ep-ai-manager": "2.5.0",
10
+ "skills/project-self-docs": "0.4.0"
11
+ }
12
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema": "1.0",
3
+ "note": "Tracks which features are installed in THIS .ai-assistance/ directory. Updated by install.sh on each deployment. Do NOT confuse with standards-version.json (ep-ai-standards compliance) -- this is .ai-assistance/ infrastructure versioning.",
4
+ "installed_at": "2026-07-14",
5
+ "installed_from": "ep-ai-standards",
6
+ "features": {},
7
+ "deploy_posture": "committed"
8
+ }
@@ -0,0 +1,2 @@
1
+ # Per-developer Claude Code local override -- never commit (machine-specific).
2
+ settings.local.json