constant-docs 0.4.0__py3-none-any.whl

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.
constant_docs/cli.py ADDED
@@ -0,0 +1,1085 @@
1
+ """Command-line interface for constant-docs.
2
+
3
+ Usage::
4
+
5
+ constant-docs verify [--json] [<config>]
6
+ constant-docs plan [--json] [<config>]
7
+ constant-docs apply <module> <body> [<config>] [--retire <id>]...
8
+ constant-docs append <module> <title> <entry> [<config>]
9
+ constant-docs add <name> [<config>] [--glob <p>]... [--covers <m>]... [--kind <k>]
10
+ constant-docs prune [<config>]
11
+ constant-docs index [<config>]
12
+ constant-docs mark [<path>...]
13
+ constant-docs settle [--json] [--hook]
14
+ constant-docs prompt [<kind>]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from constant_docs import __version__, state
26
+ from constant_docs.api import _resolve_config as discover_config
27
+ from constant_docs.api import append, apply, plan, prune, settle
28
+ from constant_docs.api import verify as verify_api
29
+ from constant_docs.config import MISSING_KEY, ConfigError, add_module
30
+ from constant_docs.config import load as load_config
31
+ from constant_docs.decisions import DecisionError
32
+ from constant_docs.document import DocumentError
33
+ from constant_docs.globs import GlobError
34
+ from constant_docs.index import write as index_write
35
+ from constant_docs.kinds import (
36
+ BUILTIN_KINDS,
37
+ DEFAULT_KIND,
38
+ KindError,
39
+ guide_names,
40
+ guide_path,
41
+ resolve_prompt,
42
+ )
43
+
44
+ _CONFIG_PATH = Path("constant-docs.yaml")
45
+
46
+ # What `init` writes when there is nothing. `modules: {}` is explicit rather
47
+ # than omitted: an absent key stays an error, so that a mistyped one cannot
48
+ # load as a repository where every check passes and nothing is checked.
49
+ _BLANK_CONFIG = """version: 1
50
+
51
+ # Where documents are written. Everything under it is excluded from every glob.
52
+ docs_root: docs
53
+
54
+ # Module key to glob. The key is the document's path stem, so `src/payments`
55
+ # writes `docs/src/payments.md`. Add one with:
56
+ #
57
+ # constant-docs add src/payments --glob 'src/payments/**/*.py'
58
+ modules: {}
59
+
60
+ # Glob to the reason it deliberately carries no document. A reason is required:
61
+ # an exclusion nobody justified is one nobody decided.
62
+ #
63
+ # uncovered:
64
+ # "tests/fixtures/**": Test data rather than source
65
+ """
66
+ _PROMPT_PATH = Path(__file__).parent / "prompts" / "module.md"
67
+
68
+ # Block counter sentinel: the guard has already released this exact set of
69
+ # stale modules, and must not block on it again until the work changes.
70
+ _RELEASED = -1
71
+
72
+
73
+ def _resolve_config(path: str | None) -> Path:
74
+ """Return the config path, discovered from the working directory.
75
+
76
+ A hook's working directory is not guaranteed to be the repository root,
77
+ so discovery walks up rather than assuming.
78
+
79
+ Discovery's own `ConfigError` propagates. Catching it and substituting
80
+ `constant-docs.yaml` threw away the sentence that says a parent walk ran
81
+ and failed, and replaced it with one naming a relative path nothing had
82
+ looked for — indistinguishable from "you passed a path and it is missing".
83
+ Every subcommand already handles a `ConfigError`, and the two that must be
84
+ silent outside a repository test its key.
85
+ """
86
+ if path:
87
+ return Path(path)
88
+ return discover_config(None)
89
+
90
+
91
+ def _take_repeated(args: list[str], flag: str) -> list[str]:
92
+ """Remove every ``--flag <value>`` pair from *args* and return the values.
93
+
94
+ Written by hand rather than reached for through `argparse`, because the
95
+ rest of this CLI parses positionally and mixing the two produces a help
96
+ text that describes neither. A flag with no value is an error rather than
97
+ a silently ignored trailing token: `--retire` with nothing after it means
98
+ the caller believes a retirement was declared.
99
+ """
100
+ values: list[str] = []
101
+ index = 0
102
+ while index < len(args):
103
+ if args[index] != flag:
104
+ index += 1
105
+ continue
106
+ if index + 1 >= len(args):
107
+ raise ValueError(f"{flag} needs a value")
108
+ values.append(args[index + 1])
109
+ del args[index : index + 2]
110
+ return values
111
+
112
+
113
+ def _exit_ok(data: Any = None) -> None:
114
+ """Print *data* as JSON if it's not None, then exit 0."""
115
+ if data is not None:
116
+ print(json.dumps(data, indent=2, default=str))
117
+ sys.exit(0)
118
+
119
+
120
+ def _exit_drift(drift_report: Any) -> None:
121
+ """Print the drift report as JSON, exit 1."""
122
+ print(json.dumps(drift_report, indent=2, default=str))
123
+ sys.exit(1)
124
+
125
+
126
+ def _exit_config_error(message: str) -> None:
127
+ """Print the error, exit 2."""
128
+ print(f"Configuration error: {message}", file=sys.stderr)
129
+ sys.exit(2)
130
+
131
+
132
+ # What an exception that reached `main` unhandled is worth as an exit code.
133
+ # 1 is drift and belongs to `verify` alone; anything that got this far is a
134
+ # refusal or a crash, and both are 2. A scheduler tells those apart by the
135
+ # code and nothing else.
136
+ _EXIT_CODES: tuple[tuple[type[BaseException], int], ...] = (
137
+ (ConfigError, 2),
138
+ (DecisionError, 2),
139
+ (DocumentError, 2),
140
+ (KindError, 2),
141
+ )
142
+ _UNEXPECTED_EXIT = 2
143
+
144
+
145
+ def _exit_code_for(exc: BaseException) -> int:
146
+ """Return the exit code for an exception that reached the top level."""
147
+ for exc_type, code in _EXIT_CODES:
148
+ if isinstance(exc, exc_type):
149
+ return code
150
+ return _UNEXPECTED_EXIT
151
+
152
+
153
+ def cmd_verify(
154
+ config_path: str | None, json_output: bool, coverage: bool = False
155
+ ) -> None:
156
+ """Run verification and report drift."""
157
+ try:
158
+ report = verify_api(_resolve_config(config_path), coverage=coverage)
159
+ except ConfigError as e:
160
+ _exit_config_error(str(e))
161
+
162
+ if json_output:
163
+ payload = {
164
+ "exit_code": report.exit_code,
165
+ "stale": report.stale,
166
+ "missing": report.missing,
167
+ "orphans": [
168
+ {
169
+ "doc_path": str(o.doc_path),
170
+ "module_key": o.module_key,
171
+ "probable_move": o.probable_move,
172
+ "probable_moves": o.probable_moves,
173
+ }
174
+ for o in report.orphans
175
+ ],
176
+ "fresh": report.fresh,
177
+ "conformance_issues": report.conformance_issues,
178
+ "content_issues": report.content_issues,
179
+ "coverage_gaps": report.coverage_gaps,
180
+ }
181
+ if report.exit_code == 0:
182
+ _exit_ok(payload)
183
+ else:
184
+ _exit_drift(payload)
185
+ else:
186
+ if report.exit_code == 0:
187
+ print("All modules up to date.")
188
+ sys.exit(0)
189
+ if report.stale:
190
+ print("Stale modules:", ", ".join(report.stale))
191
+ if report.missing:
192
+ print("Missing documents:", ", ".join(report.missing))
193
+ if report.orphans:
194
+ print(f"Orphaned documents ({len(report.orphans)}):")
195
+ for o in report.orphans:
196
+ if o.probable_move:
197
+ print(f" {o.doc_path} → probably now {o.probable_move}")
198
+ elif o.probable_moves:
199
+ candidates = ", ".join(o.probable_moves)
200
+ print(f" {o.doc_path} → one of {candidates}, cannot tell")
201
+ else:
202
+ print(f" {o.doc_path}")
203
+ if report.conformance_issues:
204
+ print(f"Conformance issues ({len(report.conformance_issues)}):")
205
+ for issue in report.conformance_issues:
206
+ print(f" {issue}")
207
+ if report.content_issues:
208
+ print(f"Content issues ({len(report.content_issues)}):")
209
+ for issue in report.content_issues:
210
+ print(f" {issue}")
211
+ if report.coverage_gaps:
212
+ print(f"Uncovered source ({len(report.coverage_gaps)}):")
213
+ for gap in report.coverage_gaps:
214
+ print(f" {gap}")
215
+ sys.exit(report.exit_code)
216
+
217
+
218
+ def cmd_plan(config_path: str | None, json_output: bool) -> None:
219
+ """Inspect and report the current state."""
220
+ try:
221
+ p = plan(_resolve_config(config_path))
222
+ except ConfigError as e:
223
+ _exit_config_error(str(e))
224
+
225
+ if json_output:
226
+ _exit_ok(_plan_payload(p))
227
+ else:
228
+ if p.stale:
229
+ for m in p.stale:
230
+ print(f"[{m.reason}] {m.module} ({m.kind}) → {m.doc_path}")
231
+ if p.fresh:
232
+ for f in p.fresh:
233
+ print(f"[fresh] {f}")
234
+ if p.orphans:
235
+ for o in p.orphans:
236
+ print(f"[orphan] {o.doc_path}")
237
+ if not p.stale and not p.orphans:
238
+ print("Everything up to date.")
239
+ sys.exit(0)
240
+
241
+
242
+ def cmd_apply(
243
+ config_path: str | None,
244
+ module_key: str,
245
+ body_text: str,
246
+ retire: list[str] | None = None,
247
+ ) -> None:
248
+ """Write a body to a module document."""
249
+ try:
250
+ config = _resolve_config(config_path)
251
+ apply(module_key, body_text, config_path=config, retire=retire or [])
252
+ print(f"Applied {module_key}.")
253
+ except ConfigError as e:
254
+ _exit_config_error(str(e))
255
+ except DecisionError as e:
256
+ # Its own clause rather than falling into the generic one, because
257
+ # the caller's next move depends on which decisions are at fault and
258
+ # a truncated message costs them a round trip.
259
+ print(f"Refused: {e}", file=sys.stderr)
260
+ sys.exit(2)
261
+ except ValueError as e:
262
+ print(f"Error: {e}", file=sys.stderr)
263
+ sys.exit(2)
264
+ except DocumentError as e:
265
+ print(f"Document error: {e}", file=sys.stderr)
266
+ sys.exit(2)
267
+
268
+
269
+ def cmd_append(
270
+ config_path: str | None, module_key: str, title: str, entry: str
271
+ ) -> None:
272
+ """Add one entry to an append-mode document."""
273
+ try:
274
+ append(module_key, entry, title, config_path=_resolve_config(config_path))
275
+ print(f"Appended to {module_key}.")
276
+ except ConfigError as e:
277
+ _exit_config_error(str(e))
278
+ except DecisionError as e:
279
+ # The same clause `cmd_apply` has. `DuplicateDecision` is a
280
+ # `DecisionError` and deliberately not a `DocumentError`, so it fell
281
+ # past all three clauses to the blanket handler and reported a refused
282
+ # write as exit 1 — the code this tool defines as drift, which a
283
+ # harness answers by retrying.
284
+ print(f"Refused: {e}", file=sys.stderr)
285
+ sys.exit(2)
286
+ except ValueError as e:
287
+ print(f"Error: {e}", file=sys.stderr)
288
+ sys.exit(2)
289
+ except DocumentError as e:
290
+ print(f"Document error: {e}", file=sys.stderr)
291
+ sys.exit(2)
292
+
293
+
294
+ def cmd_add(
295
+ config_path: str | None,
296
+ name: str,
297
+ globs: list[str],
298
+ covers: list[str],
299
+ kind: str,
300
+ ) -> int:
301
+ """Register a document in the configuration."""
302
+ try:
303
+ add_module(
304
+ _resolve_config(config_path),
305
+ name,
306
+ globs=globs,
307
+ covers=covers,
308
+ kind=kind,
309
+ )
310
+ except ConfigError as e:
311
+ print(f"Configuration error: {e}", file=sys.stderr)
312
+ return 2
313
+ print(f"Added {name}.")
314
+ return 0
315
+
316
+
317
+ def cmd_auto(config_path: str | None, json_output: bool) -> int:
318
+ """Settle, run the configured command, verify, and report.
319
+
320
+ Exit codes are the tool's usual three and must not collapse: 0 clean
321
+ afterwards, 1 still stale, 2 could not run. A command that ran and achieved
322
+ nothing is a working automation producing no result; one that could not run
323
+ is a broken automation. A scheduler that cannot tell them apart retries the
324
+ wrong one.
325
+ """
326
+ from constant_docs.auto import run as run_auto
327
+
328
+ try:
329
+ resolved = _resolve_config(config_path)
330
+ cfg = load_config(resolved)
331
+ except ConfigError as e:
332
+ print(f"Configuration error: {e}", file=sys.stderr)
333
+ return 2
334
+
335
+ if cfg.auto is None:
336
+ print(
337
+ "No 'auto' block in the configuration. Declare the command to run:"
338
+ "\n\n auto:\n command: \"<your agent> -p 'Run the constant-docs "
339
+ "settle loop'\"\n",
340
+ file=sys.stderr,
341
+ )
342
+ return 2
343
+
344
+ outcome = run_auto(cfg, resolved.resolve().parent, resolved)
345
+
346
+ if json_output:
347
+ print(
348
+ json.dumps(
349
+ {
350
+ "exit_code": outcome.exit_code,
351
+ "attempted": outcome.attempted,
352
+ "ran": outcome.ran,
353
+ "command_exit": outcome.command_exit,
354
+ "selected": outcome.selected,
355
+ "skipped": outcome.skipped,
356
+ "still_stale": outcome.still_stale,
357
+ "clean": outcome.clean,
358
+ },
359
+ indent=2,
360
+ )
361
+ )
362
+ return outcome.exit_code
363
+
364
+ if not outcome.attempted:
365
+ if outcome.clean:
366
+ print("Nothing stale; the command was not run.")
367
+ else:
368
+ # Nothing to regenerate and still not passing. Saying so is the
369
+ # whole point: the previous wording reported a clean repository
370
+ # on the strength of the one check that happened to pass.
371
+ print(
372
+ "Nothing stale, but `verify` still fails. Run "
373
+ "`constant-docs verify` for the orphans, conformance or "
374
+ "content issues behind it.",
375
+ file=sys.stderr,
376
+ )
377
+ return outcome.exit_code
378
+ if not outcome.ran:
379
+ print(
380
+ f"Could not run {cfg.auto.command!r}. Nothing was regenerated.",
381
+ file=sys.stderr,
382
+ )
383
+ return outcome.exit_code
384
+
385
+ if outcome.command_exit:
386
+ print(
387
+ f"The command exited {outcome.command_exit}. Nothing is assumed "
388
+ f"about what it did.",
389
+ file=sys.stderr,
390
+ )
391
+
392
+ if outcome.skipped:
393
+ # Named, never silent: a cap nobody is told about reads as
394
+ # "everything was covered".
395
+ print(
396
+ f"Budget of {cfg.auto.budget} reached. Left for the next run "
397
+ f"({len(outcome.skipped)}): " + ", ".join(outcome.skipped)
398
+ )
399
+
400
+ if outcome.clean:
401
+ print(f"Regenerated {len(outcome.selected)}; everything is current.")
402
+ elif outcome.still_stale:
403
+ print("Still stale: " + ", ".join(outcome.still_stale))
404
+ else:
405
+ # Not clean, nothing stale: a non-zero exit with no diagnosis is an
406
+ # operator re-running `verify` by hand to find out what happened.
407
+ print(
408
+ "Nothing is stale, but `verify` still fails. Run "
409
+ "`constant-docs verify` for what is left.",
410
+ file=sys.stderr,
411
+ )
412
+
413
+ return outcome.exit_code
414
+
415
+
416
+ def cmd_coverage(config_path: str | None, json_output: bool) -> int:
417
+ """Report source that no module covers.
418
+
419
+ Reporting is not failing: this exits 0 whatever it finds. Folding it into
420
+ the gate is `verify --coverage`, and it is opt-in for the same reason.
421
+ """
422
+ from constant_docs.coverage import by_directory, uncovered_files
423
+
424
+ try:
425
+ resolved = _resolve_config(config_path)
426
+ cfg = load_config(resolved)
427
+ except ConfigError as e:
428
+ print(f"Configuration error: {e}", file=sys.stderr)
429
+ return 2
430
+
431
+ missing = uncovered_files(cfg, resolved.resolve().parent)
432
+ grouped = by_directory(missing)
433
+
434
+ if json_output:
435
+ print(
436
+ json.dumps(
437
+ {
438
+ "uncovered": [p.as_posix() for p in missing],
439
+ "by_directory": grouped,
440
+ "excluded": dict(sorted(cfg.uncovered.items())),
441
+ },
442
+ indent=2,
443
+ )
444
+ )
445
+ return 0
446
+
447
+ if not missing:
448
+ print("Every file is covered by a module or declared uncovered.")
449
+ return 0
450
+
451
+ print(f"Source covered by no module ({len(missing)} files):")
452
+ for directory, files in grouped.items():
453
+ print(f" {directory} ({len(files)})")
454
+ print(
455
+ "\nRegister one with `constant-docs add <name> --glob <pattern>`, or "
456
+ "declare it uncovered in the configuration with a reason."
457
+ )
458
+ return 0
459
+
460
+
461
+ def cmd_init(config_path: str | None, json_output: bool) -> int:
462
+ """Gather what a harness needs to propose a module map.
463
+
464
+ Writes a configuration when there is none, and **never removes**. A re-run
465
+ is additive and proposing only: dropping a module orphans its document and
466
+ `prune` then deletes it, so periodic plus destructive would be data loss.
467
+ Removing a module stays a hand edit, because it destroys a document and
468
+ that should cost somebody a decision.
469
+ """
470
+ from constant_docs.coverage import facts
471
+
472
+ target = Path(config_path) if config_path else _CONFIG_PATH
473
+ created = False
474
+ if not target.exists():
475
+ target.write_text(_BLANK_CONFIG, encoding="utf-8")
476
+ created = True
477
+
478
+ try:
479
+ cfg = load_config(target)
480
+ except ConfigError as e:
481
+ print(f"Configuration error: {e}", file=sys.stderr)
482
+ return 2
483
+
484
+ found = facts(cfg, target.resolve().parent)
485
+
486
+ if json_output:
487
+ print(
488
+ json.dumps(
489
+ {
490
+ "created": created,
491
+ "config": str(target),
492
+ "directories": [
493
+ {
494
+ "path": d.path,
495
+ "files": d.files,
496
+ "bytes": d.bytes,
497
+ "covered": d.covered,
498
+ }
499
+ for d in found.directories
500
+ ],
501
+ "uncovered": found.uncovered,
502
+ "excluded": found.excluded,
503
+ "empty_modules": found.empty_modules,
504
+ },
505
+ indent=2,
506
+ )
507
+ )
508
+ return 0
509
+
510
+ if created:
511
+ print(f"Wrote {target}.")
512
+
513
+ for key in found.empty_modules:
514
+ print(
515
+ f"Module {key!r} matches no file. Left alone: removing it would "
516
+ f"orphan its document, which `prune` deletes."
517
+ )
518
+
519
+ proposals = _propose(found)
520
+ if not proposals:
521
+ print("Nothing to propose — every file is covered or declared uncovered.")
522
+ return 0
523
+
524
+ print(f"Source covered by no module, by directory ({len(proposals)}):\n")
525
+ for directory, count in proposals:
526
+ print(f" {directory} ({count} files)")
527
+ print(
528
+ "\nDecide what a module is here, then register each one:\n"
529
+ "\n constant-docs add <name> --glob '<pattern>'\n"
530
+ "\nA directory that should carry no document is declared in the "
531
+ "configuration under `uncovered:`, with a reason."
532
+ )
533
+ return 0
534
+
535
+
536
+ def _propose(found: Any) -> list[tuple[str, int]]:
537
+ """Return (directory, file count) for every directory with uncovered source."""
538
+ counts: dict[str, int] = {}
539
+ for rel in found.uncovered:
540
+ parent = str(Path(rel).parent)
541
+ counts[parent] = counts.get(parent, 0) + 1
542
+ return sorted(counts.items())
543
+
544
+
545
+ def cmd_prune(config_path: str | None) -> None:
546
+ """Delete orphaned documents, and name every file left alone."""
547
+ config = _resolve_config(config_path)
548
+ try:
549
+ report = prune(config)
550
+ except ConfigError as e:
551
+ _exit_config_error(str(e))
552
+ return
553
+ root = config.resolve().parent
554
+
555
+ def shown(path: Path) -> str:
556
+ try:
557
+ return path.resolve().relative_to(root).as_posix()
558
+ except ValueError:
559
+ return str(path)
560
+
561
+ # Every deletion named, one line each. `PruneReport` carries `deleted` for
562
+ # this and this alone: printing "Pruned orphans." and nothing else is how
563
+ # this command destroyed documents it had never written without anybody
564
+ # noticing, and a command that just deleted files owes the reader the list.
565
+ if report.deleted:
566
+ print(f"Pruned orphans ({len(report.deleted)}):")
567
+ for path in report.deleted:
568
+ print(f" {shown(path)}")
569
+ else:
570
+ print("No orphaned documents to prune.")
571
+
572
+ if report.failed:
573
+ # Named on stderr, because this is the one part of the output that
574
+ # says the command did not finish what it set out to do.
575
+ print(f"\nCould not remove ({len(report.failed)}):", file=sys.stderr)
576
+ for problem in report.failed:
577
+ print(f" {problem}", file=sys.stderr)
578
+
579
+ if not report.skipped:
580
+ return
581
+
582
+ # Named on every run that has any, one line each. A count alone would be
583
+ # the same silence in a shorter form: the reader cannot tell whether the
584
+ # file they care about is in it. This is a command that just deleted
585
+ # things, and the useful sentence is which ones it did not.
586
+ print(
587
+ f"\nLeft alone — carries no constant-docs frontmatter ({len(report.skipped)}):"
588
+ )
589
+ for path in report.skipped:
590
+ print(f" {shown(path)}")
591
+
592
+
593
+ def cmd_index(config_path: str | None) -> None:
594
+ """Assemble and write the root index."""
595
+ try:
596
+ config = _resolve_config(config_path)
597
+ cfg = load_config(config)
598
+ target = index_write(cfg, config.resolve().parent)
599
+ except ConfigError as e:
600
+ _exit_config_error(str(e))
601
+ except DocumentError as e:
602
+ # The index refuses to overwrite a file it did not write. A refusal is
603
+ # exit 2, the same as every other one.
604
+ print(f"Refused: {e}", file=sys.stderr)
605
+ sys.exit(2)
606
+ print(f"Wrote {target}.")
607
+
608
+
609
+ def _plan_payload(p: Any) -> dict[str, Any]:
610
+ """The JSON shape shared by `plan` and `settle` (criterion 30).
611
+
612
+ `unreadable` is here as well as in `verify` because these two are what a
613
+ regeneration loop actually drives. A module whose sources cannot be hashed
614
+ is in neither `stale` nor `fresh`, so leaving it out told the loop the
615
+ repository was fully documented while one module had dropped silently out
616
+ of the report.
617
+ """
618
+ return {
619
+ "unreadable": list(getattr(p, "unreadable", [])),
620
+ "stale": [
621
+ {
622
+ "module": m.module,
623
+ "doc_path": str(m.doc_path),
624
+ "files": [str(f) for f in m.files],
625
+ "previous_body": m.previous_body,
626
+ "previous_description": m.previous_description,
627
+ "reason": m.reason,
628
+ "kind": m.kind,
629
+ "mode": m.mode,
630
+ "required_headings": m.required_headings,
631
+ "prompt": m.prompt,
632
+ }
633
+ for m in p.stale
634
+ ],
635
+ "orphans": [
636
+ {"doc_path": str(o.doc_path), "module_key": o.module_key} for o in p.orphans
637
+ ],
638
+ "fresh": p.fresh,
639
+ }
640
+
641
+
642
+ def _paths_from_hook_stdin() -> list[str]:
643
+ """Read a PostToolUse payload from stdin and return the paths it names.
644
+
645
+ Reading the hook JSON directly is why the shipped hook needs no `jq` — a
646
+ dependency the user may not have, whose absence they would experience as
647
+ this tool being broken. Anything unparseable yields nothing, silently:
648
+ a hook that shouts about a payload it did not understand is a hook
649
+ somebody switches off.
650
+ """
651
+ try:
652
+ payload = json.load(sys.stdin)
653
+ except (ValueError, OSError):
654
+ return []
655
+ if not isinstance(payload, dict):
656
+ return []
657
+ tool_input = payload.get("tool_input")
658
+ if not isinstance(tool_input, dict):
659
+ return []
660
+
661
+ found: list[str] = []
662
+ for key in ("file_path", "notebook_path"):
663
+ value = tool_input.get(key)
664
+ if isinstance(value, str) and value:
665
+ found.append(value)
666
+ # Some edit tools carry a list of edits, each naming its own file.
667
+ edits = tool_input.get("edits")
668
+ if isinstance(edits, list):
669
+ for edit in edits:
670
+ if isinstance(edit, dict):
671
+ value = edit.get("file_path")
672
+ if isinstance(value, str) and value:
673
+ found.append(value)
674
+ return found
675
+
676
+
677
+ def cmd_mark(paths: list[str]) -> int:
678
+ """Add every module the given paths belong to, to the dirty set.
679
+
680
+ Runs on every file write, so it must be cheap and silent. It never reads
681
+ a source file and never hashes: the path is matched against the declared
682
+ patterns as a string.
683
+ """
684
+ if not paths:
685
+ paths = _paths_from_hook_stdin()
686
+ if not paths:
687
+ return 0
688
+ try:
689
+ state.mark(_resolve_config(None), paths)
690
+ except (ConfigError, GlobError, OSError):
691
+ # No configuration here, or an unreadable one. A hook installed
692
+ # globally fires in every repository, including those that do not use
693
+ # this tool, and must be invisible in them.
694
+ return 0
695
+ return 0
696
+
697
+
698
+ def cmd_settle(config_path: str | None, json_output: bool, hook: bool) -> int:
699
+ """Report the work the dirty set implies, without clearing it."""
700
+ try:
701
+ resolved = _resolve_config(config_path)
702
+ p = settle(resolved)
703
+ except ConfigError as e:
704
+ # See cmd_mark: silence outside a configured repository. A Stop hook
705
+ # that fails everywhere else is a Stop hook nobody keeps.
706
+ #
707
+ # A configuration that exists and does not parse is the opposite case
708
+ # and must not borrow that silence: it would switch the gate off in
709
+ # the one repository that does use the tool, while reporting
710
+ # "Nothing outstanding." As a hook it still allows the stop, because
711
+ # blocking on a broken file traps the agent with no way to satisfy it.
712
+ if e.key != MISSING_KEY:
713
+ print(f"Configuration error: {e}", file=sys.stderr)
714
+ return 0 if hook else 2
715
+ return 0
716
+
717
+ if hook:
718
+ return _settle_as_hook(resolved.resolve().parent, p)
719
+
720
+ if json_output:
721
+ print(json.dumps(_plan_payload(p), indent=2, default=str))
722
+ return 0
723
+
724
+ if not p.stale:
725
+ print("Nothing outstanding.")
726
+ return 0
727
+ for m in p.stale:
728
+ print(f"[{m.reason}] {m.module} ({m.kind}, {m.mode}) → {m.doc_path}")
729
+ return 0
730
+
731
+
732
+ def _settle_as_hook(repo_root: Path, p: Any) -> int:
733
+ """The Stop-hook form: exit 2 to block the stop, 0 to allow it.
734
+
735
+ Exit 2 is what makes the specification's "regeneration runs at
736
+ quiescence" true rather than hoped for — Claude Code shows stderr to the
737
+ agent and continues the turn.
738
+
739
+ Claude Code documents no loop guard for `Stop`, so this is ours: after
740
+ `MAX_BLOCKS` attempts on an identical set, step aside. An agent that
741
+ cannot satisfy the plan must not be trapped in a loop with no exit.
742
+ """
743
+ dirty = state.read(repo_root)
744
+
745
+ if not p.stale:
746
+ if dirty.blocked_signature or dirty.blocked_count:
747
+ dirty.blocked_signature, dirty.blocked_count = None, 0
748
+ state.write(repo_root, dirty)
749
+ return 0
750
+
751
+ signature = hashlib.sha256(
752
+ "\n".join(sorted(m.module for m in p.stale)).encode()
753
+ ).hexdigest()
754
+
755
+ if signature != dirty.blocked_signature:
756
+ dirty.blocked_signature, dirty.blocked_count = signature, 1
757
+ elif dirty.blocked_count < 0:
758
+ # Already released for this exact set. Blocking again would be a
759
+ # slower loop rather than no loop; stay out of the way until the work
760
+ # actually changes.
761
+ return 0
762
+ else:
763
+ dirty.blocked_count += 1
764
+
765
+ if dirty.blocked_count > state.MAX_BLOCKS:
766
+ dirty.blocked_count = _RELEASED
767
+ state.write(repo_root, dirty)
768
+ print(
769
+ (
770
+ "constant-docs: documents are still stale after "
771
+ f"{state.MAX_BLOCKS} attempts; not blocking again. Run "
772
+ "`constant-docs settle --json` when you are ready to "
773
+ "regenerate."
774
+ ),
775
+ file=sys.stderr,
776
+ )
777
+ return 0
778
+
779
+ state.write(repo_root, dirty)
780
+ lines = [
781
+ (
782
+ "constant-docs: these documents are stale and must be "
783
+ "regenerated before this turn ends."
784
+ ),
785
+ "",
786
+ ]
787
+ for m in p.stale:
788
+ lines.append(f" {m.module} ({m.kind}, {m.mode}) → {m.doc_path}")
789
+ lines += [
790
+ "",
791
+ (
792
+ "Run `constant-docs settle --json` for each module's file list, "
793
+ "previous body, required headings, and prompt."
794
+ ),
795
+ (
796
+ "Then `constant-docs apply <module> <body>` for a replace-mode "
797
+ "document, or `constant-docs append <module> <title> <entry>` "
798
+ "for an append-mode one."
799
+ ),
800
+ ]
801
+ print("\n".join(lines), file=sys.stderr)
802
+ return 2
803
+
804
+
805
+ def cmd_prompt(kind_name: str | None) -> None:
806
+ """Print the generation conventions for a kind, `module` by default.
807
+
808
+ Falls back to the shipped kinds when there is no configuration, or none
809
+ that can be read, so the command still works outside a repository — a
810
+ harness asking "how do I write one of these" should not first have to find
811
+ a config file, and a command for bootstrapping must not need the thing it
812
+ is bootstrapping.
813
+ """
814
+ # The default is the `module` kind, resolved below through the
815
+ # configuration like any other, rather than the packaged file read
816
+ # directly. The two forms disagreeing would matter most in exactly the
817
+ # repositories that redeclare `module` — which is the reason to look.
818
+ kind_name = kind_name or DEFAULT_KIND
819
+
820
+ # An authoring guide is not a kind — a README is not a tracked document —
821
+ # but "how do I write one of these" is the same question, so it is the
822
+ # same command.
823
+ guide = guide_path(kind_name)
824
+ if guide.is_file():
825
+ print(guide.read_text(encoding="utf-8"), end="")
826
+ return
827
+
828
+ # The configured kinds first, the shipped ones as the fallback. Reading
829
+ # `BUILTIN_KINDS` alone made the one command whose job is "tell me how to
830
+ # write one of these" unable to answer for a kind the repository declares —
831
+ # and its own error text then told the reader to go and open the prompt
832
+ # file by hand, which is not something a harness can do.
833
+ kinds = dict(BUILTIN_KINDS)
834
+ root = Path.cwd()
835
+ try:
836
+ config = discover_config(None)
837
+ kinds = load_config(config).kinds
838
+ root = config.resolve().parent
839
+ except (ConfigError, OSError):
840
+ # `OSError` as well as `ConfigError`: a configuration the process
841
+ # cannot even open must not take down the one command whose job is to
842
+ # explain how to write a document.
843
+ pass
844
+
845
+ kind = kinds.get(kind_name)
846
+ if kind is None:
847
+ print(
848
+ f"Unknown kind: {kind_name}. Available kinds: "
849
+ f"{', '.join(sorted(kinds))}. Authoring guides: "
850
+ f"{', '.join(guide_names())}.",
851
+ file=sys.stderr,
852
+ )
853
+ sys.exit(2)
854
+ try:
855
+ print(resolve_prompt(kind, root), end="")
856
+ except KindError as e:
857
+ print(f"Error: {e}", file=sys.stderr)
858
+ sys.exit(2)
859
+
860
+
861
+ def main(argv: list[str] | None = None) -> int:
862
+ """Entry point for the ``constant-docs`` console script.
863
+
864
+ Returns an exit code suitable for ``sys.exit()``.
865
+ """
866
+ if argv is None:
867
+ argv = sys.argv[1:]
868
+
869
+ if not argv or argv[0] in ("-h", "--help"):
870
+ _print_help()
871
+ return 0
872
+
873
+ if argv[0] in ("-V", "--version"):
874
+ print(f"constant-docs {__version__}")
875
+ return 0
876
+
877
+ subcommand = argv[0]
878
+ args = argv[1:]
879
+
880
+ # Handle --help on any subcommand
881
+ if "-h" in args or "--help" in args:
882
+ _print_subcommand_help(subcommand)
883
+ return 0
884
+
885
+ json_output = "--json" in args
886
+ if json_output:
887
+ args.remove("--json")
888
+
889
+ try:
890
+ if subcommand == "verify":
891
+ coverage = "--coverage" in args
892
+ if coverage:
893
+ args.remove("--coverage")
894
+ cmd_verify(args[0] if args else None, json_output, coverage)
895
+ elif subcommand == "plan":
896
+ cmd_plan(args[0] if args else None, json_output)
897
+ elif subcommand == "apply":
898
+ try:
899
+ retire = _take_repeated(args, "--retire")
900
+ except ValueError as e:
901
+ print(f"Error: {e}", file=sys.stderr)
902
+ return 2
903
+ if len(args) < 2:
904
+ print(
905
+ "Usage: constant-docs apply <module> <body> [<config>] "
906
+ "[--retire <id>]...",
907
+ file=sys.stderr,
908
+ )
909
+ return 2
910
+ module_key = args[0]
911
+ body_text = args[1]
912
+ config_path = args[2] if len(args) > 2 else None
913
+ cmd_apply(config_path, module_key, body_text, retire)
914
+ elif subcommand == "add":
915
+ try:
916
+ globs = _take_repeated(args, "--glob")
917
+ covers = _take_repeated(args, "--covers")
918
+ kinds = _take_repeated(args, "--kind")
919
+ except ValueError as e:
920
+ print(f"Error: {e}", file=sys.stderr)
921
+ return 2
922
+ if len(kinds) > 1:
923
+ print("Error: --kind may be given once", file=sys.stderr)
924
+ return 2
925
+ if not args:
926
+ print(
927
+ "Usage: constant-docs add <name> [<config>] "
928
+ "[--glob <pattern>]... [--covers <module>]... [--kind <kind>]",
929
+ file=sys.stderr,
930
+ )
931
+ return 2
932
+ return cmd_add(
933
+ args[1] if len(args) > 1 else None,
934
+ args[0],
935
+ globs,
936
+ covers,
937
+ kinds[0] if kinds else DEFAULT_KIND,
938
+ )
939
+ elif subcommand == "auto":
940
+ return cmd_auto(args[0] if args else None, json_output)
941
+ elif subcommand == "coverage":
942
+ return cmd_coverage(args[0] if args else None, json_output)
943
+ elif subcommand == "init":
944
+ return cmd_init(args[0] if args else None, json_output)
945
+ elif subcommand == "prune":
946
+ cmd_prune(args[0] if args else None)
947
+ elif subcommand == "index":
948
+ cmd_index(args[0] if args else None)
949
+ elif subcommand == "mark":
950
+ return cmd_mark(args)
951
+ elif subcommand == "settle":
952
+ hook = "--hook" in args
953
+ if hook:
954
+ args.remove("--hook")
955
+ return cmd_settle(args[0] if args else None, json_output, hook)
956
+ elif subcommand == "append":
957
+ if len(args) < 3:
958
+ print(
959
+ "Usage: constant-docs append <module> <title> <entry> [<config>]",
960
+ file=sys.stderr,
961
+ )
962
+ return 2
963
+ cmd_append(args[3] if len(args) > 3 else None, args[0], args[1], args[2])
964
+ elif subcommand == "prompt":
965
+ cmd_prompt(args[0] if args else None)
966
+ else:
967
+ print(f"Unknown command: {subcommand}", file=sys.stderr)
968
+ _print_help()
969
+ return 2
970
+ except SystemExit as e:
971
+ # The command handlers report by calling `sys.exit`, which is the
972
+ # right thing when the process is the caller and the wrong thing when
973
+ # a test or an embedder is. `main` is documented as returning an exit
974
+ # code, so it returns one; the console script hands it to the shell.
975
+ return 0 if e.code is None else int(e.code)
976
+ except Exception as e: # noqa: BLE001
977
+ # One place where an exception that reached the top level becomes a
978
+ # code. Two things were wrong here. A `ConfigError` was handled by
979
+ # `_exit_config_error`, which raises `SystemExit` from inside this very
980
+ # except block, where the clause above cannot catch it — so `main`
981
+ # raised where it is documented to return. And everything unhandled was
982
+ # reported as 1, which this tool defines as *drift*: a repository whose
983
+ # parser is crashing was answered by scheduling another regeneration
984
+ # against it, forever.
985
+ if isinstance(e, ConfigError):
986
+ print(f"Configuration error: {e}", file=sys.stderr)
987
+ else:
988
+ print(f"Error: {e}", file=sys.stderr)
989
+ return _exit_code_for(e)
990
+
991
+ return 0
992
+
993
+
994
+ def _print_help() -> None:
995
+ print(
996
+ "Usage: constant-docs <command> [options]\n"
997
+ "\n"
998
+ "Commands:\n"
999
+ " verify [--json] [<config>] Check for stale/missing/orphan documents\n"
1000
+ " plan [--json] [<config>] List module states\n"
1001
+ " apply <module> <body> [<config>] [--retire <id>]...\n"
1002
+ " Write a document body\n"
1003
+ " append <module> <title> <entry> [<config>] Add one log entry\n"
1004
+ " add <name> [--glob <p>]... [--covers <m>]... [--kind <k>]\n"
1005
+ " Register a document in the configuration\n"
1006
+ " auto [--json] [<config>] Run the configured regeneration command\n"
1007
+ " coverage [--json] [<config>] Report source no module covers\n"
1008
+ " init [--json] [<config>] Gather facts for a new configuration\n"
1009
+ " prune [<config>] Delete orphaned documents\n"
1010
+ " index [<config>] Rebuild the root index from descriptions\n"
1011
+ " mark [<path>...] Add a path's modules to the dirty set\n"
1012
+ " settle [--json] [--hook] Report what the dirty set implies\n"
1013
+ " prompt [<name>] Print a kind's conventions, or a guide\n"
1014
+ "\n"
1015
+ "Options:\n"
1016
+ " --json Output as JSON (verify, plan, settle, coverage, init, auto)\n"
1017
+ " -h, --help This help\n"
1018
+ " -V, --version Show version\n"
1019
+ )
1020
+
1021
+
1022
+ def _print_subcommand_help(cmd: str) -> None:
1023
+ """Print help for a specific subcommand."""
1024
+ helps = {
1025
+ "auto": (
1026
+ "Usage: constant-docs auto [--json] [<config>]\n\n"
1027
+ "Settle, run the command declared under `auto:` in the\n"
1028
+ "configuration, then verify the result. The tool calls no model;\n"
1029
+ "the command does, and its success is not taken on trust.\n\n"
1030
+ "Exits 0 when everything is clean afterwards, 1 when something is\n"
1031
+ "still stale, and 2 when the command could not run or exited\n"
1032
+ "non-zero. The last two must not be confused: a command that ran\n"
1033
+ "and achieved nothing is a working automation with no result.\n"
1034
+ ),
1035
+ "coverage": (
1036
+ "Usage: constant-docs coverage [--json] [<config>]\n\n"
1037
+ "Report every file no module covers and no `uncovered:` entry\n"
1038
+ "names, grouped by directory. Exits 0 whatever it finds;\n"
1039
+ "`verify --coverage` is the form that gates.\n"
1040
+ ),
1041
+ "init": (
1042
+ "Usage: constant-docs init [--json] [<config>]\n\n"
1043
+ "Gather what is needed to propose a module map, and write a\n"
1044
+ "configuration if there is none. Proposing only: a re-run never\n"
1045
+ "removes or rewrites an existing entry, because dropping a module\n"
1046
+ "orphans its document and `prune` then deletes it.\n"
1047
+ ),
1048
+ "verify": "Usage: constant-docs verify [--json] [--coverage] [<config>]\n\nCheck for stale, missing, or orphaned documents.\nExits 0 when everything is current, 1 on drift.\n\nOptions:\n --json Output machine-readable JSON\n",
1049
+ "plan": "Usage: constant-docs plan [--json] [<config>]\n\nList the state of every configured module.\n\nOptions:\n --json Output machine-readable JSON\n",
1050
+ "apply": (
1051
+ "Usage: constant-docs apply <module> <body> [<config>] "
1052
+ "[--retire <id>]...\n\n"
1053
+ "Write a document body for a module.\n\n"
1054
+ "A body that drops a decision the previous body recorded is\n"
1055
+ "refused, naming it. Rewording a decision is always accepted;\n"
1056
+ "only losing one is not. Retire a decision deliberately with\n"
1057
+ "--retire <id>, which records the retirement in the document.\n"
1058
+ "Declaring a retirement that did not happen is refused too, so\n"
1059
+ "the flag cannot be used to switch the check off.\n"
1060
+ ),
1061
+ "add": (
1062
+ "Usage: constant-docs add <name> [<config>] [--glob <pattern>]... "
1063
+ "[--covers <module>]... [--kind <kind>]\n\n"
1064
+ "Register a document in the configuration, so that adding one is a\n"
1065
+ "command rather than a hand edit.\n\n"
1066
+ "--covers names other modules whose files this document also takes\n"
1067
+ "in, transitively, so a cross-cutting document follows when their\n"
1068
+ "globs move instead of restating them. --glob and --covers compose.\n\n"
1069
+ "A name that already exists is refused. The result is validated by\n"
1070
+ "loading it, and the file is restored untouched if it does not\n"
1071
+ "load.\n"
1072
+ ),
1073
+ "prune": "Usage: constant-docs prune [<config>]\n\nDelete orphaned documents and empty directories.\n",
1074
+ "mark": "Usage: constant-docs mark [<path>...]\n\nAdd every module the given paths belong to, to `.constant-docs/dirty.json`.\nPass paths as arguments from any harness. With no arguments, reads a\nPostToolUse hook payload from stdin, the shape Claude Code sends.\nSilent and exit 0 when a path matches nothing, or outside a configured\nrepository.\n",
1075
+ "settle": "Usage: constant-docs settle [--json] [--hook]\n\nReport the work the dirty set implies. The set is NOT cleared by being\nread; it clears on the apply or append that satisfies each module.\n\nOptions:\n --json Machine-readable plan, the same structure `plan --json` emits\n --hook Stop-hook form: exit 2 with instructions on stderr when work is\n outstanding, exit 0 when it is not\n",
1076
+ "index": "Usage: constant-docs index [<config>]\n\nRebuild <docs_root>/index.md from every document's description.\nLeaves the file byte-identical when nothing changed.\n",
1077
+ "append": "Usage: constant-docs append <module> <title> <entry> [<config>]\n\nAdd one entry to an append-mode document. The title becomes the entry's\nH2 heading. Nothing already written is rewritten or reordered.\n",
1078
+ "prompt": (
1079
+ "Usage: constant-docs prompt [<name>]\n\n"
1080
+ "Print a kind's generation conventions, or a shipped authoring\n"
1081
+ "guide. Defaults to the `module` kind.\n"
1082
+ ),
1083
+ }
1084
+ print(helps.get(cmd, f"Unknown command: {cmd}\n"))
1085
+ print("See `constant-docs --help` for all commands.")