Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@
| **ticket-073** | [`README.md`](./ticket-073/README.md) | - | - | - | - | - |
| **ticket-074** | [`README.md`](./ticket-074/README.md) | - | - | - | - | - |
| **ticket-075** | [`README.md`](./ticket-075/README.md) | - | - | - | - | - |
| **ticket-076** | [`README.md`](./ticket-076/README.md) | - | - | - | - | - |
| **ticket-077** | [`README.md`](./ticket-077/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
45 changes: 45 additions & 0 deletions project/ticket-076/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Ticket 076: Scan Wellmanifest standard adoptions in workspace reports

- **ID**: ticket-076
- **Owner**: codex:ticket-076-standards
- **Status**: IN_PROGRESS
- **Workflow state**: VALIDATION
- **Created**: 2026-09-19

## Goal and scope

Implement the report-layer slice of GitHub issue #91: scan local Wellmanifest
adoption and lock manifests, report declared packs and enforcement levels, and
surface workspace-local pin disagreement without claiming network freshness.

SESSION_EXECUTION_AUTHORIZATION: the user explicitly instructed the agent to
continue implementation on 2026-09-19.

Continuation 2026-09-19: the user requested investigation and repair, then
explicitly instructed the agent to handle lease setup autonomously. Reused
the existing Subactor repository-change-leases store and acquired the initial
ticket-076 lease through its controller API (fencing token 36). Repair remains
inside the accepted scanner/report scope: malformed metadata, bounded traversal,
and conflicting adoption/lock pins, with regression coverage.

## Acceptance criteria

- [x] AC-01: Repositories with valid, absent and malformed adoption manifests
produce truthful structured observations.
- [x] AC-02: `monag report --sections standards` renders an adoption summary
and reports workspace-local revision disagreement.
- [x] AC-03: Focused report/governance tests and the managed governance gate
pass.

Validation after repair: `PYTHONPATH=src python3 -m pytest -q` — 345 passed,
4 skipped. `./project/governance-check.sh` — `GOV-PASS` (0 errors, 0 warnings).
Added 19 regression cases covering malformed metadata, bounded traversal,
symlink avoidance, conflicting pins, invalid UTF-8 and escaped report output.
Live read-only scan of the Semcod workspace at depth 1 observed 68
repositories, no observation errors, in 0.027 seconds. These observations do
not establish release freshness or deployment.

## Tracking boundary

This directory contains the minimal reviewed intent. Optional participant prose
and raw command logs are not required delivery output.
65 changes: 65 additions & 0 deletions project/ticket-076/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-076",
"summary": "Scan Wellmanifest standard adoptions in workspace reports",
"workstream": "application",
"classification": {
"kind": "FEATURE",
"priority": "P1",
"origin": "requested"
},
"allowedPaths": [
"project/ticket-076/**",
"TODO.md",
"project/TICKETS.md",
"src/monag/governance.py",
"src/monag/report.py",
"tests/test_governance.py",
"tests/test_report.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "08215f3bbf3c29b2642c9f9acb9acf90fbec42af",
"targetBranch": "main",
"outcome": "Provide a read-only workspace standards scanner and report section.",
"nonGoals": [
"Do not claim release freshness without an authoritative source.",
"Do not modify status or export integration owned by a later slice."
],
"complexity": "S",
"estimatedMinutes": 60,
"budgets": {
"maxImplementationFiles": 4,
"maxAffectedComponents": 2,
"maxPublicInterfaceChanges": 1,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Keep filesystem observation in a dedicated read-only module and reuse report section collection and presentation.",
"components": [
{"name": "governance scanner", "paths": ["src/monag/governance.py", "tests/test_governance.py"]},
{"name": "report", "paths": ["src/monag/report.py", "tests/test_report.py"]}
],
"responsibilityChanges": false,
"interfaceChanges": ["report section: standards"],
"dataChanges": [],
"ui": {"impact": "none", "states": [], "evidence": []},
"rollback": "Revert the protected delivery commit."
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-03",
"commands": ["PYTHONPATH=src python -m pytest -q tests/test_governance.py tests/test_report.py", "./project/governance-check.sh"],
"evidence": "Focused report tests and governance gate pass."
}
]
}
}
133 changes: 133 additions & 0 deletions src/monag/governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Read-only workspace observations for Wellmanifest adoption metadata."""
from __future__ import annotations

import json
from pathlib import Path


_SKIP_DIRS = {'.git', '.worktrees', '.venv', 'node_modules', '__pycache__'}


def _read_object(path: Path):
try:
if path.is_symlink():
return None, f'{path}: symlink metadata is not allowed'
value = json.loads(path.read_text(encoding='utf-8'))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
return None, f'{path}: {type(exc).__name__}: {exc}'
if not isinstance(value, dict):
return None, f'{path}: expected a JSON object'
return value, None


def _repositories(root: Path, depth: int, errors=None):
"""Find checkout roots, excluding linked-worktree implementation copies."""
root = Path(root)
if isinstance(depth, bool) or not isinstance(depth, int) or depth < 0:
raise ValueError('depth must be a non-negative integer')
found, pending = [], [(root, 0)]
while pending:
candidate, level = pending.pop()
try:
if candidate.is_symlink():
continue
if (candidate / '.git').exists():
found.append(candidate)
if level < depth:
children = sorted(candidate.iterdir())
pending.extend((child, level + 1) for child in children
if child.name not in _SKIP_DIRS
and not child.is_symlink() and child.is_dir())
except OSError as exc:
if errors is not None:
errors.append(f'{candidate}: {type(exc).__name__}: {exc}')
return sorted(found)


def _standards(adoption, lock, errors=None, source='manifest'):
errors = errors if errors is not None else []
standards = []
items = adoption.get('adoptions', []) if isinstance(adoption, dict) else []
if not isinstance(items, list):
errors.append(f'{source}: adoptions must be an array')
items = []
for index, item in enumerate(items):
if not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'].strip():
errors.append(f'{source}: adoptions[{index}] requires a non-empty string id')
continue
if any(item.get(key) is not None and not isinstance(item[key], str)
for key in ('version', 'revision', 'level', 'model')):
errors.append(f'{source}: adoptions[{index}] metadata must be strings or null')
continue
standards.append({**{key: item.get(key) for key in ('id', 'version', 'revision', 'level', 'model')},
'source': 'adoption'})
standard = lock.get('standard') if isinstance(lock, dict) else None
if isinstance(lock, dict) and (not isinstance(standard, dict)
or not isinstance(standard.get('id'), str) or not standard['id'].strip()
or any(standard.get(key) is not None and not isinstance(standard[key], str)
for key in ('version', 'sourceRevision'))):
errors.append(f'{source}: lock standard requires a string id and string metadata')
elif isinstance(standard, dict):
lock_pack = {
'id': standard['id'], 'version': standard.get('version'),
'revision': standard.get('sourceRevision'), 'level': None, 'model': None,
'source': 'lock',
}
matching = [pack for pack in standards if pack['id'] == lock_pack['id']]
if matching and any(pack[key] is not None and lock_pack[key] is not None
and pack[key] != lock_pack[key]
for pack in matching for key in ('version', 'revision')):
errors.append(f"{source}: adoption/lock disagreement for {lock_pack['id']}")
if not any(all(pack[key] == lock_pack[key] for key in ('id', 'version', 'revision'))
for pack in standards):
standards.append(lock_pack)
return sorted(standards, key=lambda item: item['id'])


def scan(root, depth=2):
"""Collect local adoption declarations and compare only local immutable pins."""
root = Path(root)
repositories, errors, pins = [], [], {}
for repo_root in _repositories(root, depth, errors):
adoption_path = repo_root / '.governance' / 'standard-adoption.json'
lock_path = repo_root / '.governance' / 'manifest.lock.json'
adoption = lock = None
if adoption_path.is_file():
adoption, error = _read_object(adoption_path)
if error:
errors.append(error)
if lock_path.is_file():
lock, error = _read_object(lock_path)
if error:
errors.append(error)
standards = _standards(adoption, lock, errors, str(repo_root))
for pack in standards:
revision = pack.get('revision')
if isinstance(revision, str) and revision:
pins.setdefault(pack['id'], set()).add(revision)
try:
relative = repo_root.relative_to(root)
name = relative.as_posix() if relative.parts else repo_root.name
except ValueError:
name = repo_root.name
repositories.append({
'name': name, 'path': str(repo_root),
'mode': adoption.get('mode', 'unspecified') if adoption is not None
else ('invalid' if adoption_path.exists() else 'missing'),
'profile': adoption.get('profile', '—') if adoption else '—',
'repository_role': adoption.get('repositoryRole') if adoption else None,
'has_adoption_manifest': adoption is not None,
'has_lock_manifest': lock is not None,
'standards': standards,
})
drift = [
{'id': pack, 'revisions': sorted(revisions)}
for pack, revisions in sorted(pins.items()) if len(revisions) > 1
]
return {
'schema': 'monag.governance-observation/v1', 'root': str(root),
'repository_count': len(repositories),
'adoption_manifest_count': sum(repo['has_adoption_manifest'] for repo in repositories),
'repositories': repositories, 'drift': drift, 'errors': errors,
'freshness': 'unobserved: local scan compares only declared workspace pins',
}
38 changes: 36 additions & 2 deletions src/monag/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
SCHEMA = 'monag.report/v1'

SECTION_REGISTRY = (
'status', 'prs', 'audit', 'resume', 'export', 'advise',
'status', 'prs', 'audit', 'resume', 'export', 'advise', 'standards',
)


Expand Down Expand Up @@ -184,6 +184,9 @@ def collect(root, depth=2, hours=24, sections=None, github=True,
kwargs['repos_with_worktrees'] = audit_sec.get('repos_with_worktrees')
data = advise.advise(root, depth=depth, issue_limit=issue_limit, **kwargs)
result['sections']['advise'] = data
elif section == 'standards':
from . import governance
result['sections']['standards'] = governance.scan(root, depth=depth)
except Exception as exc:
result['errors'].append(f'{section}: {type(exc).__name__}: {exc}')
result['duration_seconds'] = round(time.monotonic() - started, 2)
Expand Down Expand Up @@ -293,13 +296,44 @@ def format_section_advise(data):
return '\n'.join(lines)


def format_section_standards(data):
"""Render local Wellmanifest adoption observations without freshness claims."""
repos = data.get('repositories', [])
lines = [f"Repositories scanned: **{data.get('repository_count', len(repos))}** · "
f"Adoption manifests: **{data.get('adoption_manifest_count', 0)}** · "
f"Workspace pin disagreements: **{len(data.get('drift', []))}**"]
rows = []
for repo in repos:
packs = repo.get('standards', [])
declared = ', '.join(
f"{pack.get('id', '?')} ({pack.get('level') or '—'}; "
f"version={pack.get('version') or '—'}; revision={pack.get('revision') or '—'}; "
f"source={pack.get('source') or '—'})" for pack in packs
) or '—'
rows.append([
repo.get('name', repo.get('path', '')), repo.get('mode', 'missing'),
repo.get('profile', '—'), declared,
])
if rows:
lines.extend(['', presentation.table(['Repository', 'Mode', 'Profile', 'Declared packs'], rows)])
if data.get('drift'):
lines.extend(['', '**Workspace-local pin disagreement (not a release-freshness claim):**'])
for item in data['drift']:
lines.append(f"- {presentation.cell(item['id'])}: "
f"{presentation.cell(', '.join(item['revisions']))}")
for error in data.get('errors', []):
lines.append(f"- Observation error: {presentation.cell(error)}")
return '\n'.join(lines)


_SECTION_FORMATTERS = {
'status': format_section_status,
'prs': format_section_prs,
'audit': format_section_audit,
'resume': format_section_resume,
'export': format_section_export,
'advise': format_section_advise,
'standards': format_section_standards,
}

_SECTION_TITLES = {
Expand All @@ -309,6 +343,7 @@ def format_section_advise(data):
'resume': 'Planfile backlog and worktrees',
'export': 'Candidate work items',
'advise': 'Architectural Advisory & Task Guidance',
'standards': 'Wellmanifest standards adoption',
}


Expand Down Expand Up @@ -575,4 +610,3 @@ def run_daemon(root, emails=None, interval=3600, depth=2, hours=24,
break
stop_event.wait(min(current_interval, 5))
return results

Loading
Loading