monag 0.3.0__tar.gz

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.
monag-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: monag
3
+ Version: 0.3.0
4
+ Summary: See agent processes, concurrent checkouts and recent GitHub workspace activity in your terminal
5
+ Author: semcod
6
+ License-Expression: MIT
7
+ Classifier: Environment :: Console
8
+ Classifier: Operating System :: POSIX :: Linux
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: rich<15,>=14
13
+ Provides-Extra: dev
14
+ Requires-Dist: build<2,>=1; extra == "dev"
15
+ Requires-Dist: twine<7,>=6; extra == "dev"
16
+ Requires-Dist: setuptools>=77.0.3; extra == "dev"
17
+
18
+ # monag
19
+
20
+ A terminal view of agents working across your GitHub workspace. Linux, Python
21
+ 3.10+, Git; Rich renders Markdown in the terminal. Optional GitHub integration uses `gh`.
22
+
23
+ ```sh
24
+ python3 -m pip install .
25
+ monag
26
+ monag status
27
+ monag --machine --agents-only watch
28
+ monag --machine --agents-only --view tree watch
29
+ monag --root ~/github --markdown status > activity.md
30
+ monag --root ~/github watch
31
+ monag --root ~/github --github status
32
+ monag --root ~/github --json status
33
+ monag history --search ticket-001
34
+ monag doctor
35
+ monag run --task "Fix issue #42" --issue 42 -- claude
36
+ ./project.sh --help
37
+ ```
38
+
39
+ Running `monag` in a terminal opens the interactive shell and immediately prints
40
+ the agent dashboard plus the restart/backlog inventory. Use `refresh` to repeat
41
+ both reports, `resume` for worktrees and Planfile tickets, `audit` for the
42
+ Planfile/GitHub comparison, and `quit` or Ctrl-D to exit. `monag watch` remains
43
+ available for an always-refreshing dashboard; Ctrl-C exits it.
44
+ Use `monag status` for one snapshot. Pipes, `--json`, and `--markdown` default
45
+ to one snapshot unless `watch` is explicit.
46
+
47
+ Shows agent process count, working directories, concurrent agents in a checkout,
48
+ process trees and their working directories, changed files, recent commits,
49
+ reported tasks, searchable local history, and optional GitHub issues/PRs.
50
+ Custom agents: `--agent LABEL=EXECUTABLE` or `run --agent-kind LABEL`.
51
+ Open descriptor paths: `--open-files`. Watch records observations by default;
52
+ use `watch --no-record` to disable recording.
53
+ Put global options before `status`, `watch`, or `run`.
54
+
55
+ Planfile tickets are stored per sprint. `planfile ticket list` shows only the
56
+ `current` sprint; inspect the backlog explicitly with `planfile ticket list
57
+ --sprint backlog` (or use `monag resume`, which reads every local sprint and
58
+ legacy keyed records). To reconcile local tickets with GitHub, run the
59
+ read-only preview first:
60
+
61
+ ```sh
62
+ planfile sync github --dry-run --direction both
63
+ planfile sync github --direction both
64
+ ```
65
+
66
+ [Usage, interpretation and limitations](docs/information/usage.md) ·
67
+ [Documentation](docs/README.md)
68
+
69
+ Governance checks are available through `./project.sh` on Linux/macOS and
70
+ `project.bat` on Windows; both entry points execute the repository's pinned
71
+ governance validator before optional analysis tooling.
monag-0.3.0/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # monag
2
+
3
+ A terminal view of agents working across your GitHub workspace. Linux, Python
4
+ 3.10+, Git; Rich renders Markdown in the terminal. Optional GitHub integration uses `gh`.
5
+
6
+ ```sh
7
+ python3 -m pip install .
8
+ monag
9
+ monag status
10
+ monag --machine --agents-only watch
11
+ monag --machine --agents-only --view tree watch
12
+ monag --root ~/github --markdown status > activity.md
13
+ monag --root ~/github watch
14
+ monag --root ~/github --github status
15
+ monag --root ~/github --json status
16
+ monag history --search ticket-001
17
+ monag doctor
18
+ monag run --task "Fix issue #42" --issue 42 -- claude
19
+ ./project.sh --help
20
+ ```
21
+
22
+ Running `monag` in a terminal opens the interactive shell and immediately prints
23
+ the agent dashboard plus the restart/backlog inventory. Use `refresh` to repeat
24
+ both reports, `resume` for worktrees and Planfile tickets, `audit` for the
25
+ Planfile/GitHub comparison, and `quit` or Ctrl-D to exit. `monag watch` remains
26
+ available for an always-refreshing dashboard; Ctrl-C exits it.
27
+ Use `monag status` for one snapshot. Pipes, `--json`, and `--markdown` default
28
+ to one snapshot unless `watch` is explicit.
29
+
30
+ Shows agent process count, working directories, concurrent agents in a checkout,
31
+ process trees and their working directories, changed files, recent commits,
32
+ reported tasks, searchable local history, and optional GitHub issues/PRs.
33
+ Custom agents: `--agent LABEL=EXECUTABLE` or `run --agent-kind LABEL`.
34
+ Open descriptor paths: `--open-files`. Watch records observations by default;
35
+ use `watch --no-record` to disable recording.
36
+ Put global options before `status`, `watch`, or `run`.
37
+
38
+ Planfile tickets are stored per sprint. `planfile ticket list` shows only the
39
+ `current` sprint; inspect the backlog explicitly with `planfile ticket list
40
+ --sprint backlog` (or use `monag resume`, which reads every local sprint and
41
+ legacy keyed records). To reconcile local tickets with GitHub, run the
42
+ read-only preview first:
43
+
44
+ ```sh
45
+ planfile sync github --dry-run --direction both
46
+ planfile sync github --direction both
47
+ ```
48
+
49
+ [Usage, interpretation and limitations](docs/information/usage.md) ·
50
+ [Documentation](docs/README.md)
51
+
52
+ Governance checks are available through `./project.sh` on Linux/macOS and
53
+ `project.bat` on Windows; both entry points execute the repository's pinned
54
+ governance validator before optional analysis tooling.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "monag"
7
+ version = "0.3.0"
8
+ description = "See agent processes, concurrent checkouts and recent GitHub workspace activity in your terminal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "semcod"}]
13
+ classifiers = ["Environment :: Console", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3"]
14
+ dependencies = ["rich>=14,<15"]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["build>=1,<2", "twine>=6,<7", "setuptools>=77.0.3"]
18
+
19
+ [project.scripts]
20
+ monag = "monag.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.wellmanifest]
26
+ standard = "0.20.31"
27
+ revision = "2b016654cff1a1ccef2c0d6126a9c2550ae6b37a"
28
+ gate = "project/governance-check.sh"
29
+
30
+ [tool.pytest.ini_options]
31
+ addopts = "-p wellmanifest_governance"
32
+ pythonpath = ["src", "tests"]
monag-0.3.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Agent activity monitor."""
2
+ __version__ = "0.3.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,59 @@
1
+ """Extensible executable detection; never search prompts or arbitrary arguments."""
2
+ from pathlib import Path
3
+ import re
4
+
5
+ ALIASES = {name: name for name in (
6
+ 'codex', 'claude', 'aider', 'gemini', 'opencode', 'goose', 'cursor-agent',
7
+ 'amp', 'qwen', 'kilo', 'copilot', 'crush', 'openhands', 'continue', 'devin',
8
+ )}
9
+ MODULES = {'aider': 'aider', 'aider.main': 'aider', 'openhands': 'openhands'}
10
+ SCOPES = {'@openai/codex': 'codex', '@anthropic-ai/claude-code': 'claude',
11
+ '@google/gemini-cli': 'gemini', '@qwen-code/qwen-code': 'qwen',
12
+ '@github/copilot': 'copilot'}
13
+ # Agent Client Protocol adapters started by IDEs, e.g. claude-agent-acp or glm-acp-agent.
14
+ ACP = re.compile(r'[a-z0-9][a-z0-9_.-]*-(?:agent-acp|acp-agent|acp)')
15
+ # Helper modes selected by a leading flag are not agent sessions.
16
+ HELPER_FLAGS = {'--chrome-native-host'}
17
+
18
+
19
+ def aliases(entries=()):
20
+ result = dict(ALIASES)
21
+ for entry in entries:
22
+ kind, sep, executable = entry.partition('=')
23
+ if not sep or not re.fullmatch(r'[a-zA-Z0-9_-]+', kind) or not executable or Path(executable).name != executable:
24
+ raise ValueError('--agent must be LABEL=EXECUTABLE (a basename, without a path)')
25
+ result[executable] = kind
26
+ return result
27
+
28
+
29
+ def kind_of(name, registry):
30
+ return registry.get(name) or (name if ACP.fullmatch(name) else None)
31
+
32
+
33
+ def helper(arguments):
34
+ # Only an exact leading flag is compared; a positional prompt is never read.
35
+ return bool(arguments) and arguments[0] in HELPER_FLAGS
36
+
37
+
38
+ def identify(argv, registry=None):
39
+ registry = ALIASES if registry is None else registry
40
+ if not argv or not argv[0]:
41
+ return None, False
42
+ executable = Path(argv[0]).name
43
+ kind = kind_of(executable, registry)
44
+ if kind:
45
+ return (None, False) if helper(argv[1:]) else (kind, False)
46
+ runtime = executable in {'node', 'nodejs', 'bun', 'deno'} or re.fullmatch(r'python(?:\d+(?:\.\d+)*)?', executable)
47
+ if not runtime or len(argv) < 2:
48
+ return None, False
49
+ if argv[1] == '-m' and len(argv) > 2:
50
+ return MODULES.get(argv[2], registry.get(argv[2])), True
51
+ # Only the runtime's entrypoint is inspected, never trailing arguments.
52
+ script = argv[1]
53
+ if script.startswith('-'):
54
+ return None, False
55
+ name = Path(script).name.removesuffix('.js').removesuffix('.mjs').removesuffix('.py')
56
+ kind = kind_of(name, registry) or next((k for scope, k in SCOPES.items() if f'/{scope}/' in script), None)
57
+ if not kind or helper(argv[2:]):
58
+ return None, False
59
+ return kind, True
@@ -0,0 +1,189 @@
1
+ """Read-only audit of local Planfile ticket coverage against GitHub Issues.
2
+
3
+ For one repository (`--root` is a Git checkout) this reports how many of its
4
+ GitHub Issues have a corresponding Planfile ticket, which tickets have none,
5
+ and whether a ticket's own GitHub mapping agrees with the repository-wide
6
+ sync index (`.planfile/sync/github.state.yaml`). When `--root` is a workspace
7
+ of several repositories (not itself a checkout, e.g. the default `~/github`),
8
+ the same audit runs for every discovered repository with a GitHub remote.
9
+
10
+ `gh` failures, a missing remote, or a repository without a local Planfile are
11
+ all reported as explicit unknowns, never silently treated as "zero".
12
+ """
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from datetime import datetime, timezone
15
+ import json
16
+ from pathlib import Path
17
+ import time
18
+
19
+ import yaml
20
+
21
+ from .monitor import command, github_repo
22
+ from .presentation import table
23
+ from .resume import planfile as read_planfile, registrations, roots as discover_roots
24
+
25
+ SCHEMA = 'monag.audit/v1'
26
+
27
+
28
+ def sync_index(path):
29
+ """The repository-wide Planfile-to-GitHub mapping, if one is recorded."""
30
+ file = Path(path) / '.planfile' / 'sync' / 'github.state.yaml'
31
+ if not file.is_file():
32
+ return {}, None, None
33
+ try:
34
+ if file.stat().st_size > 2_000_000:
35
+ raise ValueError('file exceeds 2 MB read limit')
36
+ data = yaml.load(file.read_text(), Loader=getattr(yaml, 'CSafeLoader', yaml.SafeLoader))
37
+ mapping = data.get('ticket_map') if isinstance(data, dict) else None
38
+ if not isinstance(mapping, dict):
39
+ raise ValueError('expected a ticket_map mapping')
40
+ return ({str(k): str(v) for k, v in mapping.items() if v is not None},
41
+ str(file), None)
42
+ except (OSError, ValueError, TypeError, yaml.YAMLError, RecursionError) as error:
43
+ return {}, str(file), f'{file}: {type(error).__name__}'
44
+
45
+
46
+ def repo_remote(path):
47
+ remote, _ = command(['git', 'config', '--get', 'remote.origin.url'], path)
48
+ return github_repo(remote) if remote else None
49
+
50
+
51
+ def github_issues(repo, limit=200):
52
+ """All GitHub Issues (open and closed) for a repository, via `gh`."""
53
+ out, error = command(['gh', 'issue', 'list', '--repo', repo, '--state', 'all',
54
+ '--limit', str(limit), '--json',
55
+ 'number,title,state,url,updatedAt,labels'], timeout=20)
56
+ if error:
57
+ return None, [f'{repo}: {error}']
58
+ try:
59
+ issues = json.loads(out)
60
+ if not isinstance(issues, list):
61
+ raise ValueError('expected a JSON array')
62
+ return issues, []
63
+ except (ValueError, TypeError):
64
+ return None, [f'{repo}: invalid gh JSON']
65
+
66
+
67
+ def audit_repo(path, issue_limit=200):
68
+ """One repository's coverage report; never raises, errors are data."""
69
+ path = Path(path)
70
+ tickets, planfile_sources, planfile_errors = read_planfile(path)
71
+ index, index_source, index_error = sync_index(path)
72
+ repo = repo_remote(path)
73
+ issues, github_errors = (None, []) if repo is None else github_issues(repo, issue_limit)
74
+ fetched = repo is not None and not github_errors
75
+ issues = issues or []
76
+ issue_by_number = {str(item['number']): item for item in issues
77
+ if isinstance(item, dict) and 'number' in item}
78
+ mapped = {t['id']: t['github'] for t in tickets if t.get('github')}
79
+ tracked_numbers = set(mapped.values())
80
+ untracked_issues = ([item for item in issues
81
+ if str(item.get('number')) not in tracked_numbers] if fetched else [])
82
+ orphan_tickets = ([{'id': ticket_id, 'github': issue_id} for ticket_id, issue_id in mapped.items()
83
+ if issue_id not in issue_by_number] if fetched else [])
84
+ # Meaningful only once a sync index file exists: its absence is a different,
85
+ # unrelated fact from an existing index missing one ticket's entry.
86
+ sync_drift = ([{'id': ticket_id, 'github': issue_id, 'in_sync_index': index.get(ticket_id) == issue_id}
87
+ for ticket_id, issue_id in sorted(mapped.items()) if index.get(ticket_id) != issue_id]
88
+ if index_source is not None else [])
89
+ return {
90
+ 'path': str(path), 'repo': repo,
91
+ 'planfile_available': bool(planfile_sources),
92
+ 'planfile_sources': planfile_sources, 'planfile_errors': planfile_errors,
93
+ 'tickets': tickets, 'ticket_count': len(tickets), 'mapped_ticket_count': len(mapped),
94
+ 'sync_index_source': index_source, 'sync_index_error': index_error,
95
+ 'github_fetched': fetched, 'github_errors': github_errors,
96
+ 'github_issue_count': len(issues) if fetched else None,
97
+ 'github_open': sum(1 for i in issues if i.get('state') == 'OPEN') if fetched else None,
98
+ 'github_closed': sum(1 for i in issues if i.get('state') == 'CLOSED') if fetched else None,
99
+ 'untracked_issues': untracked_issues, 'orphan_tickets': orphan_tickets,
100
+ 'sync_drift': sync_drift,
101
+ }
102
+
103
+
104
+ def targets(root, depth):
105
+ """(mode, [primary checkout paths]) — one repository, or every one under root."""
106
+ root = Path(root)
107
+ if (root / '.git').exists():
108
+ records, error = registrations(root)
109
+ primary = Path(records[0]['path']) if records and not error else root
110
+ return 'repository', [primary], ([f'{root}: {error}'] if error else [])
111
+ found, seen, errors = [], set(), []
112
+ for candidate in discover_roots(root, depth):
113
+ records, error = registrations(candidate)
114
+ if error or not records:
115
+ errors.append(f'{candidate}: {error or "no worktree records"}')
116
+ continue
117
+ primary = Path(records[0]['path'])
118
+ if str(primary) in seen:
119
+ continue
120
+ seen.add(str(primary))
121
+ found.append(primary)
122
+ return 'workspace', found, errors
123
+
124
+
125
+ def scan(root, depth=2, issue_limit=200):
126
+ started = time.monotonic()
127
+ mode, paths, errors = targets(root, depth)
128
+ with ThreadPoolExecutor(max_workers=8) as pool:
129
+ repositories = list(pool.map(lambda p: audit_repo(p, issue_limit), paths))
130
+ repositories.sort(key=lambda r: (-len(r['untracked_issues']), -len(r['sync_drift']),
131
+ -len(r['orphan_tickets']), r['path']))
132
+ return {
133
+ 'schema': SCHEMA, 'root': str(root), 'mode': mode,
134
+ 'observed_at': datetime.now(timezone.utc).isoformat(),
135
+ 'duration_seconds': round(time.monotonic() - started, 2),
136
+ 'repositories': repositories, 'repository_count': len(repositories),
137
+ 'repositories_with_github': sum(1 for r in repositories if r['repo']),
138
+ 'repositories_with_planfile': sum(1 for r in repositories if r['planfile_available']),
139
+ 'total_github_issues': sum(r['github_issue_count'] or 0 for r in repositories),
140
+ 'total_planfile_tickets': sum(r['ticket_count'] for r in repositories),
141
+ 'total_untracked_issues': sum(len(r['untracked_issues']) for r in repositories),
142
+ 'total_sync_drift': sum(len(r['sync_drift']) for r in repositories),
143
+ 'errors': errors,
144
+ 'notice': 'Read-only comparison of local Planfile tickets against GitHub Issues via `gh`; '
145
+ 'no writes to either. A missing GitHub remote or failed `gh` call leaves that '
146
+ 'repository\'s issue counts unset (shown as "-"), never zero. Sync-index drift '
147
+ 'compares a ticket\'s own mapping against .planfile/sync/github.state.yaml; a '
148
+ 'missing global entry does not mean the ticket itself is wrong.',
149
+ }
150
+
151
+
152
+ def markdown(data, limit=12):
153
+ lines = ['# MONAG — Planfile / GitHub coverage audit', '',
154
+ f"Mode: **{data['mode']}** · repositories: **{data['repository_count']}** "
155
+ f"({data['repositories_with_github']} with a GitHub remote, "
156
+ f"{data['repositories_with_planfile']} with a local Planfile) · scan: "
157
+ f"{data['duration_seconds']} s.", '',
158
+ f"GitHub issues observed: **{data['total_github_issues']}** · "
159
+ f"Planfile tickets: **{data['total_planfile_tickets']}** · "
160
+ f"issues with no Planfile ticket: **{data['total_untracked_issues']}** · "
161
+ f"ticket/sync-index drift: **{data['total_sync_drift']}**.", '',
162
+ '## Repositories', '']
163
+ shown = data['repositories'][:limit]
164
+ lines.append(table(['Repository', 'GitHub issues', 'Planfile tickets', 'Mapped', 'Untracked', 'Orphan', 'Sync drift'],
165
+ [[r['repo'] or r['path'],
166
+ r['github_issue_count'] if r['github_issue_count'] is not None else '-',
167
+ r['ticket_count'] if r['planfile_available'] else '-',
168
+ r['mapped_ticket_count'], len(r['untracked_issues']),
169
+ len(r['orphan_tickets']), len(r['sync_drift'])] for r in shown]))
170
+ untracked_rows = [[r['repo'] or r['path'], '#' + str(issue.get('number')),
171
+ issue.get('state', ''), issue.get('title', '')]
172
+ for r in shown for issue in r['untracked_issues'][:limit]]
173
+ lines.extend(['', '## GitHub issues with no Planfile ticket', '',
174
+ table(['Repository', 'Issue', 'State', 'Title'], untracked_rows[:limit])])
175
+ drift_rows = [[r['repo'] or r['path'], drift['id'], drift['github'],
176
+ 'present' if drift['in_sync_index'] else 'missing/mismatched']
177
+ for r in shown for drift in r['sync_drift'][:limit]]
178
+ lines.extend(['', '## Ticket vs. sync-index drift', '',
179
+ table(['Repository', 'Ticket', 'GitHub id', 'Global sync index'], drift_rows[:limit])])
180
+ orphan_rows = [[r['repo'] or r['path'], orphan['id'], orphan['github']]
181
+ for r in shown for orphan in r['orphan_tickets'][:limit]]
182
+ if orphan_rows:
183
+ lines.extend(['', '## Planfile tickets mapped to a missing GitHub issue', '',
184
+ table(['Repository', 'Ticket', 'GitHub id'], orphan_rows[:limit])])
185
+ failures = (data['errors'] + [e for r in data['repositories'] for e in r['planfile_errors']] +
186
+ [e for r in data['repositories'] for e in r['github_errors']])
187
+ lines.extend(['', f"Read errors: {len(failures)}. Repositories shown: {min(limit, data['repository_count'])}/{data['repository_count']}.",
188
+ '', data['notice'], '', 'Full data and every row: `monag --json audit`.'])
189
+ return '\n'.join(lines) + '\n'
@@ -0,0 +1,192 @@
1
+ """Read-only catalog of local repositories: what each one is, from its own metadata.
2
+
3
+ No network calls and no LLM summarization: every field is either read verbatim
4
+ from a manifest or doc the project already ships (pyproject.toml, package.json,
5
+ README.md, ...) or a boolean/timestamp observed directly from the checkout. A
6
+ project with no declared description is reported as unknown, never guessed --
7
+ this is an index of what projects say about themselves, not an opinion.
8
+ """
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ import json
13
+ import re
14
+ import time
15
+
16
+ from .audit import targets as discover_targets
17
+ from .monitor import command, github_repo
18
+ from .presentation import table
19
+
20
+ SCHEMA = 'monag.catalog/v1'
21
+
22
+ STACK_MANIFESTS = {
23
+ 'python': ('pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'),
24
+ 'node': ('package.json',),
25
+ 'go': ('go.mod',),
26
+ 'rust': ('Cargo.toml',),
27
+ 'java': ('pom.xml', 'build.gradle', 'build.gradle.kts'),
28
+ 'docker': ('Dockerfile', 'docker-compose.yml', 'docker-compose.yaml'),
29
+ }
30
+
31
+
32
+ def detect_stacks(path):
33
+ return sorted(name for name, files in STACK_MANIFESTS.items()
34
+ if any((path / name_file).is_file() for name_file in files))
35
+
36
+
37
+ def read_text(path, limit=200_000):
38
+ try:
39
+ if not path.is_file() or path.is_symlink() or path.stat().st_size > limit:
40
+ return None
41
+ return path.read_text(errors='replace')
42
+ except OSError:
43
+ return None
44
+
45
+
46
+ def description_from_pyproject(path):
47
+ text = read_text(path / 'pyproject.toml')
48
+ if not text:
49
+ return None
50
+ match = re.search(r'(?m)^\s*description\s*=\s*"((?:[^"\\]|\\.)*)"', text)
51
+ if not match:
52
+ return None
53
+ value = match.group(1).replace('\\"', '"').strip()
54
+ return value or None
55
+
56
+
57
+ def description_from_package_json(path):
58
+ text = read_text(path / 'package.json')
59
+ if not text:
60
+ return None
61
+ try:
62
+ data = json.loads(text)
63
+ except ValueError:
64
+ return None
65
+ value = data.get('description') if isinstance(data, dict) else None
66
+ return value.strip() if isinstance(value, str) and value.strip() else None
67
+
68
+
69
+ def description_from_readme(path):
70
+ for name in ('README.md', 'README.rst', 'README.txt', 'README'):
71
+ text = read_text(path / name)
72
+ if not text:
73
+ continue
74
+ paragraph = []
75
+ for line in text.splitlines():
76
+ stripped = line.strip()
77
+ if not stripped:
78
+ if paragraph:
79
+ break
80
+ continue
81
+ if stripped.startswith(('#', '```', '![', '|', '<', '[!', '>')):
82
+ continue
83
+ paragraph.append(stripped)
84
+ if paragraph:
85
+ return ' '.join(paragraph)[:400]
86
+ return None
87
+
88
+
89
+ DESCRIPTION_SOURCES = (
90
+ (description_from_pyproject, 'pyproject.toml'),
91
+ (description_from_package_json, 'package.json'),
92
+ (description_from_readme, 'README.md'),
93
+ )
94
+
95
+
96
+ def entry_points(path):
97
+ points = set()
98
+ pyproject = read_text(path / 'pyproject.toml')
99
+ if pyproject:
100
+ section = re.search(r'(?ms)^\[project\.scripts\]\s*$(.*?)(?=^\[|\Z)', pyproject)
101
+ if section:
102
+ points.update(re.findall(r'(?m)^\s*([A-Za-z0-9_.-]+)\s*=', section.group(1)))
103
+ package_json = read_text(path / 'package.json')
104
+ if package_json:
105
+ try:
106
+ data = json.loads(package_json)
107
+ except ValueError:
108
+ data = None
109
+ if isinstance(data, dict):
110
+ bin_field = data.get('bin')
111
+ if isinstance(bin_field, dict):
112
+ points.update(k for k in bin_field if isinstance(k, str))
113
+ elif isinstance(bin_field, str) and isinstance(data.get('name'), str):
114
+ points.add(data['name'])
115
+ return sorted(points)
116
+
117
+
118
+ def last_commit(path):
119
+ out, error = command(['git', 'log', '-1', '--format=%ct%x00%s'], path)
120
+ if error or not out.strip():
121
+ return None, None
122
+ stamp, _, subject = out.strip('\n').partition('\0')
123
+ try:
124
+ when = datetime.fromtimestamp(int(stamp), timezone.utc).isoformat()
125
+ except (ValueError, OverflowError):
126
+ return None, subject or None
127
+ return when, subject or None
128
+
129
+
130
+ def describe_repo(path):
131
+ """One repository's self-declared identity; never raises, errors are data."""
132
+ path = Path(path)
133
+ remote, _ = command(['git', 'config', '--get', 'remote.origin.url'], path)
134
+ repo = github_repo(remote) if remote else None
135
+ description, source = None, None
136
+ for extractor, label in DESCRIPTION_SOURCES:
137
+ description = extractor(path)
138
+ if description:
139
+ source = label
140
+ break
141
+ commit_at, commit_subject = last_commit(path)
142
+ return {
143
+ 'path': str(path), 'name': path.name, 'repo': repo,
144
+ 'description': description, 'description_source': source,
145
+ 'stacks': detect_stacks(path), 'entry_points': entry_points(path),
146
+ 'has_docs': (path / 'docs').is_dir(),
147
+ 'has_tests': any((path / name).is_dir() for name in ('tests', 'test')),
148
+ 'has_changelog': (path / 'CHANGELOG.md').is_file(),
149
+ 'last_commit_at': commit_at, 'last_commit_subject': commit_subject,
150
+ }
151
+
152
+
153
+ def scan(root, depth=2):
154
+ started = time.monotonic()
155
+ mode, paths, errors = discover_targets(root, depth)
156
+ with ThreadPoolExecutor(max_workers=8) as pool:
157
+ repositories = list(pool.map(describe_repo, paths))
158
+ repositories.sort(key=lambda r: r['name'])
159
+ return {
160
+ 'schema': SCHEMA, 'root': str(root), 'mode': mode,
161
+ 'observed_at': datetime.now(timezone.utc).isoformat(),
162
+ 'duration_seconds': round(time.monotonic() - started, 2),
163
+ 'repositories': repositories, 'repository_count': len(repositories),
164
+ 'described_count': sum(1 for r in repositories if r['description']),
165
+ 'undescribed_count': sum(1 for r in repositories if not r['description']),
166
+ 'errors': errors,
167
+ 'notice': 'Read-only, local-only catalog: every field is read verbatim from a '
168
+ "manifest or doc the project already ships, or observed directly from "
169
+ 'the checkout. No network calls, no LLM summarization, no guessing -- '
170
+ 'a project with no declared description is reported as unknown, not '
171
+ 'invented.',
172
+ }
173
+
174
+
175
+ def markdown(data, limit=40):
176
+ lines = ['# MONAG — project catalog', '',
177
+ f"Mode: **{data['mode']}** · repositories: **{data['repository_count']}** "
178
+ f"({data['described_count']} with a declared description, "
179
+ f"{data['undescribed_count']} unknown) · scan: {data['duration_seconds']} s.",
180
+ '', '## Repositories', '']
181
+ shown = data['repositories'][:limit]
182
+ lines.append(table(['Name', 'Stacks', 'Description', 'Docs', 'Tests', 'Last commit'],
183
+ [[r['name'], ', '.join(r['stacks']) or '-',
184
+ r['description'] or '(undeclared)',
185
+ 'yes' if r['has_docs'] else 'no', 'yes' if r['has_tests'] else 'no',
186
+ (r['last_commit_at'] or '-')[:10]] for r in shown]))
187
+ undescribed = sorted(r['name'] for r in data['repositories'] if not r['description'])
188
+ if undescribed:
189
+ lines.extend(['', '## No declared description', '', ', '.join(undescribed[:limit])])
190
+ lines.extend(['', f"Repositories shown: {min(limit, data['repository_count'])}/{data['repository_count']}.",
191
+ '', data['notice'], '', 'Full data and every row: `monag --json catalog`.'])
192
+ return '\n'.join(lines) + '\n'