unharness 3.6.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.
- unharness-3.6.0/LICENSE +21 -0
- unharness-3.6.0/PKG-INFO +38 -0
- unharness-3.6.0/README.md +20 -0
- unharness-3.6.0/pyproject.toml +38 -0
- unharness-3.6.0/pyproject.toml.orig +32 -0
- unharness-3.6.0/src/unharness/__init__.py +0 -0
- unharness-3.6.0/src/unharness/cli.py +115 -0
- unharness-3.6.0/src/unharness/compile.py +103 -0
- unharness-3.6.0/src/unharness/config.py +226 -0
- unharness-3.6.0/src/unharness/digests.py +285 -0
- unharness-3.6.0/src/unharness/doctor.py +324 -0
- unharness-3.6.0/src/unharness/launch.py +127 -0
- unharness-3.6.0/src/unharness/preamble.md +13 -0
- unharness-3.6.0/src/unharness/resolve.py +88 -0
- unharness-3.6.0/src/unharness/session.py +29 -0
unharness-3.6.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arthur Ryzhak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
unharness-3.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unharness
|
|
3
|
+
Version: 3.6.0
|
|
4
|
+
Summary: Compile a manifesto stack into a session's system prompt and launch Claude Code with it.
|
|
5
|
+
Keywords: claude-code,system-prompt,manifesto
|
|
6
|
+
Author: Arthur Ryzhak
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Project-URL: Homepage, https://github.com/ryzhakar/llm-manifestos
|
|
15
|
+
Project-URL: Repository, https://github.com/ryzhakar/llm-manifestos
|
|
16
|
+
Project-URL: Documentation, https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/unharness/README.md
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# unharness
|
|
20
|
+
|
|
21
|
+
Compile a project's manifesto stack into a Claude Code session's system prompt, and launch the session with the harness's own instructions switched off.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
/compile-commitments
|
|
25
|
+
uvx unharness prompt | uvx unharness launch -- claude
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
To run the source at the v3.6.0 tag instead, use `uvx --from "git+https://github.com/ryzhakar/llm-manifestos@v3.6.0#subdirectory=plugin/unharness" unharness` in place of `uvx unharness`. The launcher's input is reopened on the terminal.
|
|
29
|
+
|
|
30
|
+
- `unharness prompt [--config PATH] [--digests] [--stack-digests] [--overlap] [--minified]` prints the compiled prompt. `--digests` and `--stack-digests` swap manifesto texts for digests, `--overlap` lets stack digests share a manifesto, and `--minified` loads the min pair, the two `.min.md` session files; the Unharness section of [`plugin/README.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/README.md) states the cover rule.
|
|
31
|
+
- `unharness stack [--config PATH] [SLUG ...]` prints the `<stack>` block and the constitution; given slugs, it prints those manifestos alone.
|
|
32
|
+
- `unharness stamp DIGEST [--config PATH]` writes the digest's entry into its `index.json`, as in `unharness stamp .claude/digests/dry.md`.
|
|
33
|
+
- `unharness doctor [--config PATH] [--digests] [--stack-digests] [--overlap] [--minified]` prints one `OK`, `WARN`, or `FAIL` line per check and exits 1 on any FAIL.
|
|
34
|
+
- `unharness launch [--keep-claude-md] [--keep-web-search] [--no-hooks] [PROMPT] [-- LAUNCHER [ARGS...]]` caches the prompt and runs the launcher, or prints the command when none follows `--`.
|
|
35
|
+
|
|
36
|
+
The config schema is in [`plugin/SCHEMA.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/SCHEMA.md). The strip table and the text that stays are in the Unharness section of [`plugin/README.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/README.md).
|
|
37
|
+
|
|
38
|
+
Test with `uv run --project plugin/unharness pytest -q` from the repository root.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# unharness
|
|
2
|
+
|
|
3
|
+
Compile a project's manifesto stack into a Claude Code session's system prompt, and launch the session with the harness's own instructions switched off.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
/compile-commitments
|
|
7
|
+
uvx unharness prompt | uvx unharness launch -- claude
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
To run the source at the v3.6.0 tag instead, use `uvx --from "git+https://github.com/ryzhakar/llm-manifestos@v3.6.0#subdirectory=plugin/unharness" unharness` in place of `uvx unharness`. The launcher's input is reopened on the terminal.
|
|
11
|
+
|
|
12
|
+
- `unharness prompt [--config PATH] [--digests] [--stack-digests] [--overlap] [--minified]` prints the compiled prompt. `--digests` and `--stack-digests` swap manifesto texts for digests, `--overlap` lets stack digests share a manifesto, and `--minified` loads the min pair, the two `.min.md` session files; the Unharness section of [`plugin/README.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/README.md) states the cover rule.
|
|
13
|
+
- `unharness stack [--config PATH] [SLUG ...]` prints the `<stack>` block and the constitution; given slugs, it prints those manifestos alone.
|
|
14
|
+
- `unharness stamp DIGEST [--config PATH]` writes the digest's entry into its `index.json`, as in `unharness stamp .claude/digests/dry.md`.
|
|
15
|
+
- `unharness doctor [--config PATH] [--digests] [--stack-digests] [--overlap] [--minified]` prints one `OK`, `WARN`, or `FAIL` line per check and exits 1 on any FAIL.
|
|
16
|
+
- `unharness launch [--keep-claude-md] [--keep-web-search] [--no-hooks] [PROMPT] [-- LAUNCHER [ARGS...]]` caches the prompt and runs the launcher, or prints the command when none follows `--`.
|
|
17
|
+
|
|
18
|
+
The config schema is in [`plugin/SCHEMA.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/SCHEMA.md). The strip table and the text that stays are in the Unharness section of [`plugin/README.md`](https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/README.md).
|
|
19
|
+
|
|
20
|
+
Test with `uv run --project plugin/unharness pytest -q` from the repository root.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unharness"
|
|
3
|
+
version = "3.6.0"
|
|
4
|
+
description = "Compile a manifesto stack into a session's system prompt and launch Claude Code with it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"claude-code",
|
|
10
|
+
"system-prompt",
|
|
11
|
+
"manifesto",
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
]
|
|
19
|
+
requires-python = ">=3.11"
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[[project.authors]]
|
|
23
|
+
name = "Arthur Ryzhak"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/ryzhakar/llm-manifestos"
|
|
27
|
+
Repository = "https://github.com/ryzhakar/llm-manifestos"
|
|
28
|
+
Documentation = "https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/unharness/README.md"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
unharness = "unharness.cli:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12.23,<0.13"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = ["pytest>=8"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unharness"
|
|
3
|
+
version = "3.6.0"
|
|
4
|
+
description = "Compile a manifesto stack into a session's system prompt and launch Claude Code with it."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Arthur Ryzhak" }]
|
|
9
|
+
keywords = ["claude-code", "system-prompt", "manifesto"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.11"
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/ryzhakar/llm-manifestos"
|
|
21
|
+
Repository = "https://github.com/ryzhakar/llm-manifestos"
|
|
22
|
+
Documentation = "https://github.com/ryzhakar/llm-manifestos/blob/v3.6.0/plugin/unharness/README.md"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
unharness = "unharness.cli:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.12.23,<0.13"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=8"]
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The unharness command: print the compiled prompt, print the stack a session binds to, or launch a session."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unharness.compile import FULL_FILES, MINIFIED_FILES, compile_prompt, stack_view
|
|
9
|
+
from unharness.config import EMPTY_STACK_MESSAGE, UnharnessError, fail, load
|
|
10
|
+
from unharness.digests import DIGESTS_FLAG, OVERLAP_FLAG, STACK_DIGESTS_FLAG, constitution_blocks, digest_mode, stamp_digest
|
|
11
|
+
from unharness.doctor import Modes, claude_version, doctor, has_failed
|
|
12
|
+
from unharness.launch import Options, cache_home, execute_launcher, launch, reopen_terminal, terminal_device
|
|
13
|
+
from unharness.resolve import login_dir, manifesto_elements, manifesto_plugin_dir, resolve_stack
|
|
14
|
+
from unharness.session import load_preamble, read_if_present
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG = Path(".manifestos.yaml")
|
|
17
|
+
PROMPT_VERB = "prompt"
|
|
18
|
+
STACK_VERB = "stack"
|
|
19
|
+
STAMP_VERB = "stamp"
|
|
20
|
+
LAUNCH_VERB = "launch"
|
|
21
|
+
DOCTOR_VERB = "doctor"
|
|
22
|
+
MINIFIED_FLAG = "--minified"
|
|
23
|
+
MODE_FLAGS = (DIGESTS_FLAG, STACK_DIGESTS_FLAG, OVERLAP_FLAG, MINIFIED_FLAG)
|
|
24
|
+
LAUNCHER_SEPARATOR = "--"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> int:
|
|
28
|
+
"""Run the verb named in argv; print its output, or fail in one line with exit status 1."""
|
|
29
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
30
|
+
argv, launcher = _split_launcher(argv)
|
|
31
|
+
parser = argparse.ArgumentParser(prog="unharness")
|
|
32
|
+
verbs = parser.add_subparsers(dest="verb", required=True)
|
|
33
|
+
prompt_parser = verbs.add_parser(PROMPT_VERB)
|
|
34
|
+
stack_parser = verbs.add_parser(STACK_VERB)
|
|
35
|
+
stack_parser.add_argument("slugs", nargs="*")
|
|
36
|
+
for parser_of_verb in (prompt_parser, stack_parser):
|
|
37
|
+
parser_of_verb.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
38
|
+
for flag in MODE_FLAGS:
|
|
39
|
+
prompt_parser.add_argument(flag, action="store_true")
|
|
40
|
+
stamp_parser = verbs.add_parser(STAMP_VERB)
|
|
41
|
+
stamp_parser.add_argument("digest", type=Path)
|
|
42
|
+
stamp_parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
43
|
+
launch_parser = verbs.add_parser(LAUNCH_VERB)
|
|
44
|
+
launch_parser.add_argument("prompt", nargs="?")
|
|
45
|
+
for flag in ("--keep-claude-md", "--keep-web-search", "--no-hooks"):
|
|
46
|
+
launch_parser.add_argument(flag, action="store_true")
|
|
47
|
+
doctor_parser = verbs.add_parser(DOCTOR_VERB)
|
|
48
|
+
doctor_parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
49
|
+
for flag in MODE_FLAGS:
|
|
50
|
+
doctor_parser.add_argument(flag, action="store_true")
|
|
51
|
+
arguments = parser.parse_args(argv)
|
|
52
|
+
try:
|
|
53
|
+
if arguments.verb == LAUNCH_VERB:
|
|
54
|
+
return _launch(arguments, launcher)
|
|
55
|
+
if arguments.verb == DOCTOR_VERB:
|
|
56
|
+
return _doctor(arguments)
|
|
57
|
+
login = login_dir(os.environ, Path.home())
|
|
58
|
+
print(_stamp(arguments, login) if arguments.verb == STAMP_VERB else _output(arguments, login))
|
|
59
|
+
except UnharnessError as error:
|
|
60
|
+
return fail(error)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_launcher(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
65
|
+
"""Split a launch command line at its first --, into the verb's own arguments and the launcher with its arguments."""
|
|
66
|
+
if argv[:1] != [LAUNCH_VERB] or LAUNCHER_SEPARATOR not in argv:
|
|
67
|
+
return argv, []
|
|
68
|
+
separator = argv.index(LAUNCHER_SEPARATOR)
|
|
69
|
+
return argv[:separator], argv[separator + 1:]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _launch(arguments: argparse.Namespace, launcher: list[str]) -> int:
|
|
73
|
+
if arguments.prompt is None and sys.stdin.isatty():
|
|
74
|
+
raise UnharnessError("no prompt: pass PROMPT or pipe unharness prompt")
|
|
75
|
+
prompt = arguments.prompt if arguments.prompt is not None else sys.stdin.read()
|
|
76
|
+
options = Options(arguments.keep_claude_md, arguments.keep_web_search, arguments.no_hooks, launcher)
|
|
77
|
+
return launch(prompt, options, os.environ, cache_home(os.environ, Path.home()), terminal_device(), reopen_terminal, execute_launcher)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _stamp(arguments: argparse.Namespace, login: Path) -> str:
|
|
81
|
+
index = stamp_digest(arguments.digest, load(arguments.config).registry, login)
|
|
82
|
+
return f"stamped {arguments.digest.stem} in {index}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _doctor(arguments: argparse.Namespace) -> int:
|
|
86
|
+
modes = Modes(arguments.digests, arguments.stack_digests, arguments.overlap, arguments.minified)
|
|
87
|
+
findings = doctor(arguments.config, modes, login_dir(os.environ, Path.home()), os.environ, claude_version)
|
|
88
|
+
print("\n".join(finding.line for finding in findings))
|
|
89
|
+
return 1 if has_failed(findings) else 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _output(arguments: argparse.Namespace, login: Path) -> str:
|
|
93
|
+
verb = arguments.verb
|
|
94
|
+
mode = digest_mode(arguments.digests, arguments.stack_digests, arguments.overlap) if verb == PROMPT_VERB else None
|
|
95
|
+
config = load(arguments.config)
|
|
96
|
+
if verb == STACK_VERB and arguments.slugs:
|
|
97
|
+
return stack_view(resolve_stack(manifesto_elements(arguments.slugs), config.directory, config.registry, login))
|
|
98
|
+
if not config.you:
|
|
99
|
+
raise UnharnessError(EMPTY_STACK_MESSAGE)
|
|
100
|
+
documents = resolve_stack(config.you, config.directory, config.registry, login)
|
|
101
|
+
if verb == STACK_VERB:
|
|
102
|
+
return stack_view(documents)
|
|
103
|
+
session_files = MINIFIED_FILES if arguments.minified else FULL_FILES
|
|
104
|
+
return compile_prompt(
|
|
105
|
+
documents,
|
|
106
|
+
load_preamble(config.directory).text,
|
|
107
|
+
read_if_present(config.directory / session_files.interplay),
|
|
108
|
+
read_if_present(config.directory / session_files.commitments),
|
|
109
|
+
constitution_blocks(config.you, documents, config.directory, manifesto_plugin_dir(config.registry, login), mode),
|
|
110
|
+
session_files,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Compile resolved documents and the session's own files into the system prompt."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from unharness.config import UnharnessError
|
|
8
|
+
from unharness.resolve import Document
|
|
9
|
+
|
|
10
|
+
STACK_BLOCK = re.compile(r"\A<stack>\n(.*?)\n</stack>\n*", re.S)
|
|
11
|
+
INTERPLAY_FILE = ".manifestos.interplay.md"
|
|
12
|
+
COMMITMENTS_FILE = ".manifestos.commitments.md"
|
|
13
|
+
MINIFIED_INTERPLAY_FILE = ".manifestos.interplay.min.md"
|
|
14
|
+
MINIFIED_COMMITMENTS_FILE = ".manifestos.commitments.min.md"
|
|
15
|
+
DOUBLE_QUOTE = '"'
|
|
16
|
+
SINGLE_QUOTE = "'"
|
|
17
|
+
WRITING_SKILL = "/compile-commitments"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Block:
|
|
22
|
+
name: str
|
|
23
|
+
purpose: str | None
|
|
24
|
+
text: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SessionFiles:
|
|
29
|
+
interplay: str
|
|
30
|
+
commitments: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
FULL_FILES = SessionFiles(INTERPLAY_FILE, COMMITMENTS_FILE)
|
|
34
|
+
MINIFIED_FILES = SessionFiles(MINIFIED_INTERPLAY_FILE, MINIFIED_COMMITMENTS_FILE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compile_prompt(
|
|
38
|
+
documents: list[Document], preamble: str, interplay: str | None, commitments: str | None,
|
|
39
|
+
constitution: Sequence[Block | Document] | None = None, files: SessionFiles = FULL_FILES,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Return the system prompt: preamble, constitution, interplay, commitments; stop if a session file is stale."""
|
|
42
|
+
session_files = [
|
|
43
|
+
_current_body(files.interplay, interplay, documents),
|
|
44
|
+
_current_body(files.commitments, commitments, documents),
|
|
45
|
+
]
|
|
46
|
+
return "\n\n".join([harness_block(preamble), constitution_block(documents if constitution is None else constitution), *session_files])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def harness_block(preamble: str) -> str:
|
|
50
|
+
"""Return the preamble in its tags, the first block of the system prompt."""
|
|
51
|
+
return f"<harness>\n{preamble.strip()}\n</harness>"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def stack_view(documents: list[Document]) -> str:
|
|
55
|
+
"""Return the stack block, then the constitution: what a session binds to."""
|
|
56
|
+
return f"{stack_block(documents)}\n\n{constitution_block(documents)}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stack_block(documents: list[Document]) -> str:
|
|
60
|
+
"""Return the stack block: one line per document, in order."""
|
|
61
|
+
return "<stack>\n" + "\n".join(_stack_lines(documents)) + "\n</stack>"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def constitution_block(blocks: Sequence[Block | Document]) -> str:
|
|
65
|
+
"""Return the constitution block: one tagged text per block, in order."""
|
|
66
|
+
return "<constitution>\n" + "\n".join(_tagged(block) for block in blocks) + "\n</constitution>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stack_lines(documents: list[Document]) -> list[str]:
|
|
70
|
+
return [f"{document.name} {document.sha256} {document.purpose or ''}".rstrip() for document in documents]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tagged(block: Block | Document) -> str:
|
|
74
|
+
governs = f' governs="{block.purpose.replace(DOUBLE_QUOTE, SINGLE_QUOTE)}"' if block.purpose else ""
|
|
75
|
+
return f"<{block.name}{governs}>\n{block.text}\n</{block.name}>"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _current_body(file_name: str, text: str | None, documents: list[Document]) -> str:
|
|
79
|
+
"""Return the session file without its stack block, or stop if the file is missing or stale."""
|
|
80
|
+
if text is None:
|
|
81
|
+
raise UnharnessError(f"{file_name} is missing; run {WRITING_SKILL}")
|
|
82
|
+
recorded = STACK_BLOCK.match(text)
|
|
83
|
+
if not recorded:
|
|
84
|
+
raise UnharnessError(f"{file_name} has no <stack> block; run {WRITING_SKILL}")
|
|
85
|
+
current_lines = _stack_lines(documents)
|
|
86
|
+
recorded_lines = recorded.group(1).split("\n")
|
|
87
|
+
if recorded_lines != current_lines:
|
|
88
|
+
raise UnharnessError(f"{file_name} is stale ({_difference(recorded_lines, current_lines)}); run {WRITING_SKILL}")
|
|
89
|
+
return text[recorded.end():].strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _difference(recorded_lines: list[str], current_lines: list[str]) -> str:
|
|
93
|
+
changed = sorted({line.split(" ")[0] for line in set(recorded_lines) ^ set(current_lines)})
|
|
94
|
+
return f"changed: {', '.join(changed)}" if changed else "reordered"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def session_file_problem(file_name: str, text: str | None, documents: list[Document]) -> str | None:
|
|
98
|
+
"""Return why the session file would stop the prompt, None when it is current."""
|
|
99
|
+
try:
|
|
100
|
+
_current_body(file_name, text, documents)
|
|
101
|
+
except UnharnessError as error:
|
|
102
|
+
return str(error)
|
|
103
|
+
return None
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Read a project's .manifestos.yaml into a Config, with the YAML subset reader the file needs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
TEXT_ENCODING = "utf-8"
|
|
13
|
+
LIST_MARKER = "- "
|
|
14
|
+
MANIFESTO_SOURCE = "manifesto"
|
|
15
|
+
SKILL_SOURCE = "skill"
|
|
16
|
+
FILE_SOURCE = "file"
|
|
17
|
+
CLAUDE_MD = "CLAUDE.md"
|
|
18
|
+
EMPTY_STACK_MESSAGE = "the stack is empty"
|
|
19
|
+
ACTIVE_MANIFESTOS_SECTION = re.compile(r"^## Active Manifestos\n(.*?)(?=^## |\Z)", re.M | re.S)
|
|
20
|
+
SOURCE = re.compile(rf"^({MANIFESTO_SOURCE}|{SKILL_SOURCE}|{FILE_SOURCE}) (\S.*)$")
|
|
21
|
+
MISSING_CONFIG_STATUS = 2
|
|
22
|
+
MAPPING_ENTRY = re.compile(r":(?:\s|$)")
|
|
23
|
+
WHITESPACE = re.compile(r"\s")
|
|
24
|
+
TAB_INDENT = re.compile(r" *\t")
|
|
25
|
+
YAML_NULLS = ("", "~", "null")
|
|
26
|
+
QUOTED_SCALAR = re.compile(r"""^(["'])(.*?)\1(?:\s+#.*)?$""")
|
|
27
|
+
TRAILING_COMMENT = re.compile(r"(?:^|\s+)#.*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnharnessError(Exception):
|
|
31
|
+
"""A failure whose one-line message tells the owner what to fix."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MissingConfigError(UnharnessError):
|
|
35
|
+
"""No config file and no CLAUDE.md section: a project that does not use manifestos."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Element:
|
|
40
|
+
name: str
|
|
41
|
+
purpose: str | None
|
|
42
|
+
source_type: str | None
|
|
43
|
+
reference: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Config:
|
|
48
|
+
you: list[Element]
|
|
49
|
+
subagents: dict[str, list[Element]]
|
|
50
|
+
directory: Path
|
|
51
|
+
registry: Path | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fail(error: UnharnessError, status: int = 1) -> int:
|
|
55
|
+
"""Print the failure as one line on stderr and return the exit status."""
|
|
56
|
+
print(f"unharness: {error}", file=sys.stderr)
|
|
57
|
+
return status
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str]) -> int:
|
|
61
|
+
"""Print the config at argv's path as JSON for a shell hook; fail in one line, with status 2 when no config exists."""
|
|
62
|
+
parser = argparse.ArgumentParser(description="Print a .manifestos.yaml as JSON.")
|
|
63
|
+
parser.add_argument("path", type=Path)
|
|
64
|
+
parser.add_argument("--json", action="store_true", required=True)
|
|
65
|
+
try:
|
|
66
|
+
config = load(parser.parse_args(argv).path)
|
|
67
|
+
except MissingConfigError as error:
|
|
68
|
+
return fail(error, MISSING_CONFIG_STATUS)
|
|
69
|
+
except UnharnessError as error:
|
|
70
|
+
return fail(error)
|
|
71
|
+
print(json.dumps({
|
|
72
|
+
"you": [asdict(element) for element in config.you],
|
|
73
|
+
"subagents": {role: [asdict(element) for element in elements] for role, elements in config.subagents.items()},
|
|
74
|
+
"directory": str(config.directory),
|
|
75
|
+
"registry": str(config.registry) if config.registry else "",
|
|
76
|
+
}))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(path: Path) -> Config:
|
|
81
|
+
"""Read the config file at path into a Config."""
|
|
82
|
+
root = parse_yaml(_config_text(path))
|
|
83
|
+
if isinstance(root, list):
|
|
84
|
+
root = {"you": root}
|
|
85
|
+
root = root or {}
|
|
86
|
+
roles = root.get("subagents") or {}
|
|
87
|
+
if not isinstance(roles, dict):
|
|
88
|
+
raise UnharnessError("subagents must be a mapping of role to list of elements")
|
|
89
|
+
return Config(
|
|
90
|
+
you=_elements(root.get("you") or [], "you"),
|
|
91
|
+
subagents={role: _elements(items or [], f"subagents.{role}") for role, items in roles.items()},
|
|
92
|
+
directory=path.parent,
|
|
93
|
+
registry=(path.parent / root["manifesto_dir"]).resolve() if root.get("manifesto_dir") else None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _config_text(path: Path) -> str:
|
|
98
|
+
if path.is_file():
|
|
99
|
+
return path.read_text(encoding=TEXT_ENCODING)
|
|
100
|
+
claude_md = path.parent / CLAUDE_MD
|
|
101
|
+
section = ACTIVE_MANIFESTOS_SECTION.search(claude_md.read_text(encoding=TEXT_ENCODING)) if claude_md.is_file() else None
|
|
102
|
+
if not section:
|
|
103
|
+
raise MissingConfigError(f"no config: tried {path} and the Active Manifestos section of {claude_md}")
|
|
104
|
+
return section.group(1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _elements(items: list, list_label: str) -> list[Element]:
|
|
108
|
+
if not isinstance(items, list):
|
|
109
|
+
raise UnharnessError(f"{list_label} must be a list of elements")
|
|
110
|
+
present = [item for item in items if item is not None]
|
|
111
|
+
return [_element(item, f"element {number} of {list_label}") for number, item in enumerate(present, 1)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _element(item: str | dict, label: str) -> Element:
|
|
115
|
+
fields = item if isinstance(item, dict) else {"name": item}
|
|
116
|
+
source_type, reference = _source(fields.get("source"), label)
|
|
117
|
+
name = fields.get("name") or (Path(reference).stem if source_type == FILE_SOURCE else None)
|
|
118
|
+
if not name:
|
|
119
|
+
raise UnharnessError(f"{label} has no name; add name: or a file source")
|
|
120
|
+
if WHITESPACE.search(name):
|
|
121
|
+
raise UnharnessError(f"{label} name {name!r} must hold no whitespace; add name:")
|
|
122
|
+
return Element(name, fields.get("purpose"), source_type, reference)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _source(source: str | None, label: str) -> tuple[str | None, str | None]:
|
|
126
|
+
"""Split a source into its type and reference; a missing source is a registry manifesto, named by the element."""
|
|
127
|
+
if source is None:
|
|
128
|
+
return None, None
|
|
129
|
+
parsed = SOURCE.match(source)
|
|
130
|
+
if not parsed:
|
|
131
|
+
raise UnharnessError(
|
|
132
|
+
f"{label} has source {source!r} that is none of {MANIFESTO_SOURCE} <name>, "
|
|
133
|
+
f"{SKILL_SOURCE} <plugin>:<skill>, {SKILL_SOURCE} <skill>, or {FILE_SOURCE} <path>; "
|
|
134
|
+
f"write a path as {FILE_SOURCE} <path>"
|
|
135
|
+
)
|
|
136
|
+
return parsed.group(1), parsed.group(2)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_yaml(text: str):
|
|
140
|
+
"""Return the structure of block-style YAML text: mappings, lists, and string scalars; None if empty."""
|
|
141
|
+
lines = [line.rstrip() for line in text.split("\n") if line.strip() and not line.lstrip().startswith("#")]
|
|
142
|
+
if not lines:
|
|
143
|
+
return None
|
|
144
|
+
for line in lines:
|
|
145
|
+
if TAB_INDENT.match(line):
|
|
146
|
+
raise UnharnessError(f"the line {line.strip()!r} is indented with a tab; indent with spaces")
|
|
147
|
+
value, consumed = _parse_block(lines, 0, _indent(lines[0]))
|
|
148
|
+
_require_consumed(lines, consumed)
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _require_consumed(lines: list[str], consumed: int) -> None:
|
|
153
|
+
if consumed < len(lines):
|
|
154
|
+
raise UnharnessError(f"cannot place the line {lines[consumed].strip()!r}; check its indent")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _indent(line: str) -> int:
|
|
158
|
+
return len(line) - len(line.lstrip(" "))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scalar(text: str) -> str | None:
|
|
162
|
+
text = text.strip()
|
|
163
|
+
quoted = QUOTED_SCALAR.match(text)
|
|
164
|
+
if quoted:
|
|
165
|
+
return quoted.group(2)
|
|
166
|
+
text = TRAILING_COMMENT.sub("", text)
|
|
167
|
+
return None if text in YAML_NULLS else text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _split_entry(text: str) -> tuple[str, str]:
|
|
171
|
+
entry = MAPPING_ENTRY.search(text)
|
|
172
|
+
return (text[:entry.start()], text[entry.end():]) if entry else (text, "")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _is_item(line: str) -> bool:
|
|
176
|
+
stripped = line.lstrip()
|
|
177
|
+
return stripped == LIST_MARKER.strip() or stripped.startswith(LIST_MARKER)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_block(lines: list[str], i: int, indent: int):
|
|
181
|
+
if _is_item(lines[i]):
|
|
182
|
+
return _parse_list(lines, i, indent)
|
|
183
|
+
return _parse_mapping(lines, i, indent)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_list(lines: list[str], i: int, indent: int):
|
|
187
|
+
result = []
|
|
188
|
+
item_indent = indent + len(LIST_MARKER)
|
|
189
|
+
while i < len(lines) and _indent(lines[i]) == indent and _is_item(lines[i]):
|
|
190
|
+
content = lines[i].lstrip()[len(LIST_MARKER):]
|
|
191
|
+
if not MAPPING_ENTRY.search(content):
|
|
192
|
+
result.append(_scalar(content))
|
|
193
|
+
i += 1
|
|
194
|
+
continue
|
|
195
|
+
block = [" " * item_indent + content]
|
|
196
|
+
i += 1
|
|
197
|
+
while i < len(lines) and _indent(lines[i]) >= item_indent:
|
|
198
|
+
block.append(lines[i])
|
|
199
|
+
i += 1
|
|
200
|
+
value, consumed = _parse_block(block, 0, item_indent)
|
|
201
|
+
_require_consumed(block, consumed)
|
|
202
|
+
result.append(value)
|
|
203
|
+
return result, i
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_mapping(lines: list[str], i: int, indent: int):
|
|
207
|
+
result = {}
|
|
208
|
+
while i < len(lines) and _indent(lines[i]) == indent and not _is_item(lines[i]):
|
|
209
|
+
key, rest = _split_entry(lines[i].lstrip())
|
|
210
|
+
key = _scalar(key)
|
|
211
|
+
if key in result:
|
|
212
|
+
raise UnharnessError(f"key {key!r} appears twice")
|
|
213
|
+
value = _scalar(rest)
|
|
214
|
+
if value is not None:
|
|
215
|
+
result[key] = value
|
|
216
|
+
i += 1
|
|
217
|
+
elif i + 1 < len(lines) and _indent(lines[i + 1]) > indent:
|
|
218
|
+
result[key], i = _parse_block(lines, i + 1, _indent(lines[i + 1]))
|
|
219
|
+
else:
|
|
220
|
+
result[key] = None
|
|
221
|
+
i += 1
|
|
222
|
+
return result, i
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main(sys.argv[1:]))
|