ecoportal-api-graphql 1.3.12 → 1.3.14
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.
- checksums.yaml +4 -4
- data/.ai-assistance/bridge/archive/oscar-a1b2c3d-gitlab-mcp-doc-update.inbox.md +29 -29
- data/.ai-assistance/bridge/archive/oscar-c912c25-gemini-design-review.inbox.md +42 -42
- data/.ai-assistance/projects/compat-layer-audit/COMPAT_AUDIT.md +244 -0
- data/.ai-assistance/projects/template-model-logic/CATEGORY_CATALOG.md +236 -0
- data/.ai-assistance/projects/template-model-logic/CLASSIFICATION_SPIKE.md +243 -0
- data/.ai-assistance/projects/template-model-logic/DESIGN_NOTE.md +154 -0
- data/.ai-assistance/scripts/bridge-inbox-check.sh +75 -0
- data/.ai-assistance/skills/project-self-docs/SKILL.md +181 -0
- data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
- data/.ai-assistance/standards-version.json +9 -9
- data/.claude/settings.json +150 -146
- data/.gitignore +37 -37
- data/.gitlab-ci.yml +45 -0
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +1 -0
- data/docs/self-docs/ARCHITECTURE.md +3 -3
- data/docs/self-docs/CHANGES.jsonl +5 -0
- data/docs/self-docs/COMPLIANCE.md +79 -0
- data/docs/self-docs/CONVENTIONS.md +3 -3
- data/docs/self-docs/INTEGRATIONS.md +17 -15
- data/docs/self-docs/OPERATIONS.md +76 -0
- data/docs/self-docs/OVERVIEW.md +16 -3
- data/docs/self-docs/STATUS.md +9 -5
- data/docs/self-docs/self-docs-index.json +20 -8
- data/lib/ecoportal/api/common/graphql/http_client.rb +189 -177
- data/lib/ecoportal/api/graphql/base/location_classification_type.rb +15 -15
- data/lib/ecoportal/api/graphql/base/location_structure.rb +27 -27
- data/lib/ecoportal/api/graphql/base/organization.rb +15 -15
- data/lib/ecoportal/api/graphql/base.rb +35 -35
- data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +41 -41
- data/lib/ecoportal/api/graphql/connection/action.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/action_category.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/page.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/person_member.rb +11 -11
- data/lib/ecoportal/api/graphql/connection/preview_page.rb +11 -11
- data/lib/ecoportal/api/graphql/connection.rb +17 -17
- data/lib/ecoportal/api/graphql/fragment/location_node.rb +26 -26
- data/lib/ecoportal/api/graphql/input/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +21 -21
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +28 -28
- data/lib/ecoportal/api/graphql/input/workflow_command/edit_page_creator_permissions.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +23 -23
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +20 -20
- data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +18 -18
- data/lib/ecoportal/api/graphql/input/workflow_command.rb +251 -251
- data/lib/ecoportal/api/graphql/input.rb +24 -24
- data/lib/ecoportal/api/graphql/logic/connection.rb +34 -34
- data/lib/ecoportal/api/graphql/model/person_member.rb +15 -15
- data/lib/ecoportal/api/graphql/model.rb +30 -30
- data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +14 -14
- data/lib/ecoportal/api/graphql/query/location_structures.rb +57 -57
- data/lib/ecoportal/api/graphql/query.rb +27 -27
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +11 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bridge-inbox-check.sh -- session-start actuator for the bridge inbox.
|
|
3
|
+
#
|
|
4
|
+
# Closes an actuation-coverage gap: unread cross-project bridge messages used to be
|
|
5
|
+
# visible only if someone manually ran a command. This is the trigger/clock that
|
|
6
|
+
# surfaces them automatically at session start, mirroring the standards-inbox report
|
|
7
|
+
# line (see CLAUDE.md and standards/governance/standards-request-protocol.md).
|
|
8
|
+
#
|
|
9
|
+
# DETERMINISTIC and zero-LLM: it just scans .ai-assistance/bridge/inbox/ for unread
|
|
10
|
+
# messages (STATUS: PENDING) and prints one summary line plus one line per message:
|
|
11
|
+
#
|
|
12
|
+
# [bridge] N unread message(s)
|
|
13
|
+
# - {title} from {FROM}
|
|
14
|
+
#
|
|
15
|
+
# Contract (mirrors the other hook scripts):
|
|
16
|
+
# - Silent (no output) and exit 0 when the inbox is empty or has no unread messages.
|
|
17
|
+
# - NEVER fails fatally -- always exits 0, even on a malformed file or missing dir.
|
|
18
|
+
# - No side effects: read-only, does not touch or move any message.
|
|
19
|
+
#
|
|
20
|
+
# Wired as a SessionStart hook in .claude/settings.json:
|
|
21
|
+
# bash scripts/bridge-inbox-check.sh 2>/dev/null || true
|
|
22
|
+
#
|
|
23
|
+
# Manual run (same output): bash scripts/bridge-inbox-check.sh
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
# Resolve the bridge inbox from this script's location so it works from any cwd.
|
|
27
|
+
# This script is deployed at .ai-assistance/scripts/, so the inbox is a sibling
|
|
28
|
+
# of the scripts dir: ../bridge/inbox (mirrors bridge-init.sh in this repo).
|
|
29
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)" || exit 0
|
|
30
|
+
INBOX="$SCRIPT_DIR/../bridge/inbox"
|
|
31
|
+
|
|
32
|
+
# No inbox -> nothing to report.
|
|
33
|
+
[ -d "$INBOX" ] || exit 0
|
|
34
|
+
|
|
35
|
+
# An "unread" message is a *.md file whose header carries STATUS: PENDING.
|
|
36
|
+
# (PENDING = not yet picked up by the receiving agent; IN_PROGRESS/DONE/FAILED are read.)
|
|
37
|
+
unread=""
|
|
38
|
+
count=0
|
|
39
|
+
|
|
40
|
+
for f in "$INBOX"/*.md; do
|
|
41
|
+
# Guard against the literal glob when the folder is empty.
|
|
42
|
+
[ -e "$f" ] || continue
|
|
43
|
+
|
|
44
|
+
# Only inspect the header block (first ~15 lines) -- cheap and format-stable.
|
|
45
|
+
header="$(head -n 15 "$f" 2>/dev/null || true)"
|
|
46
|
+
|
|
47
|
+
# Unread == STATUS: PENDING (case-insensitive, tolerant of surrounding whitespace).
|
|
48
|
+
printf '%s\n' "$header" | grep -Eiq '^[[:space:]]*STATUS:[[:space:]]*PENDING[[:space:]]*$' || continue
|
|
49
|
+
|
|
50
|
+
# Title: the text after "# TASK:" on line 1; fall back to the filename.
|
|
51
|
+
title="$(printf '%s\n' "$header" \
|
|
52
|
+
| grep -Ei '^#[[:space:]]*TASK:' \
|
|
53
|
+
| head -n 1 \
|
|
54
|
+
| sed -E 's/^#[[:space:]]*TASK:[[:space:]]*//' 2>/dev/null || true)"
|
|
55
|
+
[ -n "$title" ] || title="$(basename "$f" .md)"
|
|
56
|
+
|
|
57
|
+
# Sender: the text after "FROM:"; fall back to "unknown".
|
|
58
|
+
from="$(printf '%s\n' "$header" \
|
|
59
|
+
| grep -Ei '^[[:space:]]*FROM:' \
|
|
60
|
+
| head -n 1 \
|
|
61
|
+
| sed -E 's/^[[:space:]]*FROM:[[:space:]]*//I' 2>/dev/null || true)"
|
|
62
|
+
[ -n "$from" ] || from="unknown"
|
|
63
|
+
|
|
64
|
+
count=$((count + 1))
|
|
65
|
+
unread="${unread}- ${title} from ${from}
|
|
66
|
+
"
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
# Silent when there is nothing unread.
|
|
70
|
+
[ "$count" -gt 0 ] || exit 0
|
|
71
|
+
|
|
72
|
+
printf '[bridge] %d unread message(s)\n' "$count"
|
|
73
|
+
printf '%s' "$unread"
|
|
74
|
+
|
|
75
|
+
exit 0
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: project-self-docs
|
|
3
|
+
category: knowledge
|
|
4
|
+
version: 0.2.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
|
+
These docs are COMMITTED (not gitignored) -- they are the shareable artefact, unlike the
|
|
108
|
+
gitignored `.ai-assistance/local/` context that `ai-discovery` writes.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Workflow
|
|
113
|
+
|
|
114
|
+
1. **Scan.** Run `self_docs_scan.py --json`. Read the observation.
|
|
115
|
+
2. **Reuse.** Pull facts from the existing inputs it lists. Only genuinely-missing semantic content
|
|
116
|
+
is yours to write.
|
|
117
|
+
3. **Fill / refresh.** For each self-doc: if missing, copy the template and fill it; if present,
|
|
118
|
+
update only the sections whose underlying facts changed (structure, hot paths, status). Keep
|
|
119
|
+
`review_status: draft` on anything a human has not confirmed.
|
|
120
|
+
4. **Batch heavy semantic work to Gemini.** For a large or unfamiliar repo, draft
|
|
121
|
+
ARCHITECTURE/OVERVIEW via the `gemini-assist` skill (no-training tier) rather than on
|
|
122
|
+
interactive Claude tokens -- per the token-frugality rule. Verify Gemini's output against the
|
|
123
|
+
scanner facts before writing.
|
|
124
|
+
5. **Write the index + changelog (deterministic).** Run:
|
|
125
|
+
```bash
|
|
126
|
+
python .ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py --write
|
|
127
|
+
```
|
|
128
|
+
This rewrites `docs/self-docs/self-docs-index.json` (the machine surface the hub reads) and
|
|
129
|
+
APPENDS one line per changed doc to `docs/self-docs/CHANGES.jsonl`. A no-op re-run appends
|
|
130
|
+
nothing -- this is what makes the capability idempotent.
|
|
131
|
+
6. **Human review before any egress.** Leave publishing to a human-reviewed step (see below).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Publish / egress -- CONFIGURABLE, default local-only (OPEN DECISION)
|
|
136
|
+
|
|
137
|
+
The Confluence publish destination is an OPEN DECISION being made separately (existing EPAI space
|
|
138
|
+
vs a new dedicated hub space vs the private EP-AI-MGMT space). Therefore:
|
|
139
|
+
|
|
140
|
+
- **Default: write to the repo/local filesystem ONLY.** This skill does not push to Confluence.
|
|
141
|
+
- The publish target is a PARAMETER, not a hardcoded value. When the decision is made, the egress
|
|
142
|
+
step becomes a call to the existing seed pipeline (`scripts/seed-epai-project-docs.py` pattern)
|
|
143
|
+
pointed at the chosen space, gated by human review.
|
|
144
|
+
- <!-- TODO(hub-target): wire egress to the chosen Confluence space once the publish-target
|
|
145
|
+
decision lands. Until then, self-docs live in docs/self-docs/ and are consumed from git. -->
|
|
146
|
+
|
|
147
|
+
Never emit `r` (a result) straight to a shared surface: a human review gate sits between the
|
|
148
|
+
generated self-docs and any hub publish. This is a damping control -- keep it.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Actuation
|
|
153
|
+
|
|
154
|
+
- **Actor:** a developer (or `ai-discovery`/`project-cycle` calling this skill) IN THE REPO THAT
|
|
155
|
+
DOCUMENTS ITSELF, never an unattended agent -- and NEVER an ep-ai-standards session reaching into
|
|
156
|
+
another repo to generate that repo's self-docs. ep-ai-standards AUTHORS + DEPLOYS this skill and
|
|
157
|
+
AGGREGATES/publishes results; each repo RUNS it in its own session. (Running it on ep-ai-standards
|
|
158
|
+
ITSELF is fine -- that is why `docs/self-docs/` exists here.)
|
|
159
|
+
- **Trigger / clock:** on demand; recommended at project cycle-end (`project-cycle`) and after a
|
|
160
|
+
structural change. There is no autonomous fleet-wide trigger yet -- that depends on the unbuilt
|
|
161
|
+
ADR-014 upkeep loop; note this rather than inventing an actuator.
|
|
162
|
+
- **Coverage on neglect:** if self-docs are never refreshed, the scanner's `content_hash` +
|
|
163
|
+
`source_head` in the index let the hub (and `project-review`) flag them as stale. The doc is
|
|
164
|
+
never silently trusted; staleness is detectable.
|
|
165
|
+
- **Write authority:** writes ONLY under the self-docs directory (default `docs/self-docs/`), plus
|
|
166
|
+
the KPI usage line. It does not touch source code, `.ai-assistance/local/`, git remotes, or any
|
|
167
|
+
network target.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## What you do not do
|
|
172
|
+
|
|
173
|
+
- Do not invent a documentation format -- use the fixed self-doc set and templates.
|
|
174
|
+
- Do not re-analyse the codebase from scratch -- reuse `ai-discovery`, `code-specs`, and the
|
|
175
|
+
scanner; propose running those skills if their inputs are missing.
|
|
176
|
+
- Do not publish to Confluence or any network target -- egress is a separate, human-gated,
|
|
177
|
+
as-yet-undecided step.
|
|
178
|
+
- Do not write outside `docs/self-docs/` (or the configured self-docs dir).
|
|
179
|
+
- Do not run heavy semantic drafting on interactive Claude tokens for large repos -- batch to
|
|
180
|
+
Gemini (no-training tier).
|
|
181
|
+
- 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())
|
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
],
|
|
7
7
|
"deferred": [],
|
|
8
8
|
"installed-components": {
|
|
9
|
+
"skills/ai-instructions": "0.0.0",
|
|
9
10
|
"skills/code-specs": "0.0.0",
|
|
10
|
-
"skills/ep-ai-manager": "2.1.0",
|
|
11
|
-
"skills/dep-graph": "0.0.0",
|
|
12
11
|
"skills/corporate-policies": "1.0.0",
|
|
13
|
-
"skills/
|
|
14
|
-
"skills/
|
|
12
|
+
"skills/dep-graph": "0.0.0",
|
|
13
|
+
"skills/ep-ai-manager": "2.2.0",
|
|
15
14
|
"skills/gemini-assist": "0.0.0",
|
|
16
|
-
"skills/ruby-scripting": "0.1.0",
|
|
17
|
-
"skills/refactor": "0.0.0",
|
|
18
|
-
"skills/project-cycle": "0.0.0",
|
|
19
15
|
"skills/graphql-schema-analysis": "0.0.0",
|
|
20
|
-
"skills/
|
|
16
|
+
"skills/project-cycle": "0.0.0",
|
|
17
|
+
"skills/project-self-docs": "0.2.0",
|
|
18
|
+
"skills/refactor": "0.0.0",
|
|
19
|
+
"skills/ruby-scripting": "0.1.0",
|
|
20
|
+
"skills/spec-generation": "0.0.0"
|
|
21
21
|
}
|
|
22
|
-
}
|
|
22
|
+
}
|