project-kit-cli 1.1.0__tar.gz → 1.2.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.
- project_kit_cli-1.1.0/README.md → project_kit_cli-1.2.0/PKG-INFO +40 -6
- project_kit_cli-1.1.0/PKG-INFO → project_kit_cli-1.2.0/README.md +19 -25
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/pyproject.toml +5 -1
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/pyproject.toml.orig +5 -1
- project_kit_cli-1.2.0/src/project_kit/actions.py +459 -0
- project_kit_cli-1.2.0/src/project_kit/assets/docs/automate.md +86 -0
- project_kit_cli-1.2.0/src/project_kit/assets/skills/pk-automate/SKILL.md +61 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-next/SKILL.md +18 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-provision/SKILL.md +2 -1
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-quiet/SKILL.md +3 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-start/SKILL.md +7 -3
- project_kit_cli-1.2.0/src/project_kit/assets/subagents/pk-provisioner.md +19 -0
- project_kit_cli-1.2.0/src/project_kit/assets/subagents/pk-runner.md +19 -0
- project_kit_cli-1.2.0/src/project_kit/assets/subagents/pk-tester.md +24 -0
- project_kit_cli-1.2.0/src/project_kit/claude_hooks.py +141 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/cli.py +180 -4
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/config.py +13 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/context_files.py +11 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/doctor.py +7 -5
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/entry.py +17 -3
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/help.py +2 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/installer.py +13 -6
- project_kit_cli-1.2.0/src/project_kit/models.py +153 -0
- project_kit_cli-1.1.0/src/project_kit/claude_hooks.py +0 -100
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/LICENSE +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/__init__.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/__main__.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/agents.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/ai_attribution.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/analyzers.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/approval.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/agents.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/backlog-schema.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/example.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/flow.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/handoff.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/provisioning.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/quickstart.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/quiet.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/session.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/skills.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/statusline.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/docs/upgrade.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/hooks/commit-msg +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/project/skills-catalog.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/project/workflow.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-amend/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-decide/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-discover/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-foundations/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-grill/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-handoff/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-import/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-plan/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-skills/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-verify/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/skills/pk-workflow/SKILL.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/subagents/pk-reviewer.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/adr.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/backlog.example.json +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/brief.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/constitution-input.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/development-workflow.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/github/pull_request_template.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/gitlab/issue_templates/adr.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/gitlab/issue_templates/bug.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/gitlab/issue_templates/epic.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/gitlab/issue_templates/feature.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/gitlab/merge_request_templates/Default.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/assets/templates/interview.md +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/backlog.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/fsutil.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/gh.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/glab.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/handoff.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/labels.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/logview.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/manifest.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/migrate.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/next_step.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/peek.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/procs.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/project_choice.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/providers/__init__.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/providers/github.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/providers/gitlab.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/provision.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/provision_gitlab.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/provision_issues.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/quiet.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/redact.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/schema.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/skills.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/sources.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/state.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/statusline.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/templating.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/trace.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/uninstall.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/updates.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/upgrade.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/userdocs.py +0 -0
- {project_kit_cli-1.1.0 → project_kit_cli-1.2.0}/src/project_kit/workflow.py +0 -0
|
@@ -1,7 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: project-kit-cli
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Project definition and dev-workflow kit that hands off to Spec Kit
|
|
5
|
+
Keywords: project-management,spec-kit,claude-code,workflow,github
|
|
6
|
+
Author: Miguel Tomás
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Project-URL: Homepage, https://miguelmatg.github.io/project-kit-site/
|
|
19
|
+
Project-URL: Documentation, https://miguelmatg.github.io/project-kit-site/
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
1
22
|
# project-kit
|
|
2
23
|
|
|
3
24
|
[](https://pypi.org/project/project-kit-cli/)
|
|
4
25
|
[](https://pypi.org/project/project-kit-cli/)
|
|
26
|
+
[](https://miguelmatg.github.io/project-kit-site/)
|
|
27
|
+
|
|
28
|
+
**Website:** [miguelmatg.github.io/project-kit-site](https://miguelmatg.github.io/project-kit-site/),
|
|
29
|
+
with the phases, what to ask your agent and the commands, in English and Spanish.
|
|
5
30
|
|
|
6
31
|
**Everything that happens before the first line of code.** project-kit guides you, with an AI
|
|
7
32
|
agent, through defining a project, deciding its stack and architecture, agreeing how the team
|
|
@@ -82,8 +107,13 @@ Outside the sequence: `/pk-next` (runs the sequence for you), `/pk-grill` (hard
|
|
|
82
107
|
contradictions, offered before each approval), `/pk-import` (start from existing documents),
|
|
83
108
|
`/pk-skills`, `/pk-amend`, `/pk-verify` and `/pk-quiet` (runs long commands and reads long logs
|
|
84
109
|
through `pjkit run`, `logs` and `peek` so that only what failed reaches the AI; it pays off with
|
|
85
|
-
long output, see `pjkit docs quiet`)
|
|
86
|
-
|
|
110
|
+
long output, see `pjkit docs quiet`) and `/pk-automate` (turns code the AI keeps writing into a
|
|
111
|
+
saved script it runs instead). Each phase ends with `pjkit approve <phase>`, which only you run.
|
|
112
|
+
A small project can skip three phases with `pjkit init --mode quick`.
|
|
113
|
+
|
|
114
|
+
In Claude Code, work that needs no judgment goes to subagents with their own context and, if you
|
|
115
|
+
want, a smaller model: `pk-tester` (tests and checks), `pk-provisioner` (an approved provision),
|
|
116
|
+
`pk-runner` (saved scripts) and `pk-reviewer` (verification). See `pjkit docs automate`.
|
|
87
117
|
|
|
88
118
|
## Commands
|
|
89
119
|
|
|
@@ -102,22 +132,26 @@ you run. A small project can skip three phases with `pjkit init --mode quick`.
|
|
|
102
132
|
| `pjkit uninstall [--apply] [--purge]` | Undo `init`: remove what project-kit installed and you did not edit |
|
|
103
133
|
| `pjkit run -- <command>` | Run a command and print a small report instead of all its output (`logs`, `peek` and `stats` go with it) |
|
|
104
134
|
| `pjkit mode [quick\|full]` | Show or change how many phases the project goes through |
|
|
135
|
+
| `pjkit models [set]` | Which model each Claude Code subagent uses |
|
|
136
|
+
| `pjkit actions [scripts]` | Code the AI wrote again and again, and the scripts saved instead |
|
|
105
137
|
| `pjkit context` | Write or refresh the project block in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
|
|
106
138
|
| `pjkit skills list\|show\|mark` | Recommended third-party skills and what the project decided |
|
|
107
139
|
| `pjkit statusline [--install]` | A line for Claude Code's status bar: model, context, limits, project phase |
|
|
108
|
-
| `pjkit hook install\|remove` | Tell Claude Code the project's state at the start of each session |
|
|
140
|
+
| `pjkit hook install\|remove` | Tell Claude Code the project's state at the start of each session, and remember the code it writes |
|
|
109
141
|
| `pjkit docs [topic]` | The guides |
|
|
110
142
|
| `pjkit doctor` / `pjkit help` | Check the tools / a visual guide |
|
|
111
143
|
|
|
112
144
|
Useful `init` options: `--from <path>`, `--mode quick|full`, `--no-context`, `--project-name`,
|
|
113
145
|
`--language` (of the generated documents), `--agents claude,codex,...`, `--agent claude|none`,
|
|
114
|
-
`--provider github|gitlab|none
|
|
146
|
+
`--provider github|gitlab|none`, `--ai-coauthor keep|remove` and `--models recommended|inherit`.
|
|
115
147
|
|
|
116
148
|
## What it adds to your repository
|
|
117
149
|
|
|
118
150
|
```
|
|
119
151
|
.project-kit/ config.yml, state.json, workflow.yml, templates/, handoff/
|
|
120
152
|
.claude/skills/pk-*/ the skills (.agents/skills for the other agents)
|
|
153
|
+
.claude/agents/pk-* the subagents (Claude Code only)
|
|
154
|
+
.project-kit/scripts/ scripts saved with /pk-automate (catalog: .project-kit/scripts.yml)
|
|
121
155
|
docs/project/ brief.md, roadmap.md (created by the phases)
|
|
122
156
|
docs/adr/ one file per decision (created by the phases)
|
|
123
157
|
.githooks/commit-msg only if you removed the AI co-author
|
|
@@ -129,8 +163,8 @@ date and `pjkit upgrade --apply` brings it up to date without touching what you
|
|
|
129
163
|
|
|
130
164
|
## Documentation
|
|
131
165
|
|
|
132
|
-
`pjkit docs` lists every guide: `quickstart`, `example`, `session`, `flow`, `quiet`, `
|
|
133
|
-
`provisioning`, `handoff`, `backlog-schema`, `skills`, `statusline` and `agents`.
|
|
166
|
+
`pjkit docs` lists every guide: `quickstart`, `example`, `session`, `flow`, `quiet`, `automate`,
|
|
167
|
+
`upgrade`, `provisioning`, `handoff`, `backlog-schema`, `skills`, `statusline` and `agents`.
|
|
134
168
|
|
|
135
169
|
## License
|
|
136
170
|
|
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: project-kit-cli
|
|
3
|
-
Version: 1.1.0
|
|
4
|
-
Summary: Project definition and dev-workflow kit that hands off to Spec Kit
|
|
5
|
-
Keywords: project-management,spec-kit,claude-code,workflow,github
|
|
6
|
-
Author: Miguel Tomás
|
|
7
|
-
License-Expression: MIT
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Classifier: Development Status :: 3 - Alpha
|
|
10
|
-
Classifier: Environment :: Console
|
|
11
|
-
Classifier: Intended Audience :: Developers
|
|
12
|
-
Classifier: Operating System :: OS Independent
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
-
Classifier: Topic :: Software Development
|
|
16
|
-
Requires-Dist: pyyaml>=6.0.3
|
|
17
|
-
Requires-Python: >=3.12
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
1
|
# project-kit
|
|
21
2
|
|
|
22
3
|
[](https://pypi.org/project/project-kit-cli/)
|
|
23
4
|
[](https://pypi.org/project/project-kit-cli/)
|
|
5
|
+
[](https://miguelmatg.github.io/project-kit-site/)
|
|
6
|
+
|
|
7
|
+
**Website:** [miguelmatg.github.io/project-kit-site](https://miguelmatg.github.io/project-kit-site/),
|
|
8
|
+
with the phases, what to ask your agent and the commands, in English and Spanish.
|
|
24
9
|
|
|
25
10
|
**Everything that happens before the first line of code.** project-kit guides you, with an AI
|
|
26
11
|
agent, through defining a project, deciding its stack and architecture, agreeing how the team
|
|
@@ -101,8 +86,13 @@ Outside the sequence: `/pk-next` (runs the sequence for you), `/pk-grill` (hard
|
|
|
101
86
|
contradictions, offered before each approval), `/pk-import` (start from existing documents),
|
|
102
87
|
`/pk-skills`, `/pk-amend`, `/pk-verify` and `/pk-quiet` (runs long commands and reads long logs
|
|
103
88
|
through `pjkit run`, `logs` and `peek` so that only what failed reaches the AI; it pays off with
|
|
104
|
-
long output, see `pjkit docs quiet`)
|
|
105
|
-
|
|
89
|
+
long output, see `pjkit docs quiet`) and `/pk-automate` (turns code the AI keeps writing into a
|
|
90
|
+
saved script it runs instead). Each phase ends with `pjkit approve <phase>`, which only you run.
|
|
91
|
+
A small project can skip three phases with `pjkit init --mode quick`.
|
|
92
|
+
|
|
93
|
+
In Claude Code, work that needs no judgment goes to subagents with their own context and, if you
|
|
94
|
+
want, a smaller model: `pk-tester` (tests and checks), `pk-provisioner` (an approved provision),
|
|
95
|
+
`pk-runner` (saved scripts) and `pk-reviewer` (verification). See `pjkit docs automate`.
|
|
106
96
|
|
|
107
97
|
## Commands
|
|
108
98
|
|
|
@@ -121,22 +111,26 @@ you run. A small project can skip three phases with `pjkit init --mode quick`.
|
|
|
121
111
|
| `pjkit uninstall [--apply] [--purge]` | Undo `init`: remove what project-kit installed and you did not edit |
|
|
122
112
|
| `pjkit run -- <command>` | Run a command and print a small report instead of all its output (`logs`, `peek` and `stats` go with it) |
|
|
123
113
|
| `pjkit mode [quick\|full]` | Show or change how many phases the project goes through |
|
|
114
|
+
| `pjkit models [set]` | Which model each Claude Code subagent uses |
|
|
115
|
+
| `pjkit actions [scripts]` | Code the AI wrote again and again, and the scripts saved instead |
|
|
124
116
|
| `pjkit context` | Write or refresh the project block in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` |
|
|
125
117
|
| `pjkit skills list\|show\|mark` | Recommended third-party skills and what the project decided |
|
|
126
118
|
| `pjkit statusline [--install]` | A line for Claude Code's status bar: model, context, limits, project phase |
|
|
127
|
-
| `pjkit hook install\|remove` | Tell Claude Code the project's state at the start of each session |
|
|
119
|
+
| `pjkit hook install\|remove` | Tell Claude Code the project's state at the start of each session, and remember the code it writes |
|
|
128
120
|
| `pjkit docs [topic]` | The guides |
|
|
129
121
|
| `pjkit doctor` / `pjkit help` | Check the tools / a visual guide |
|
|
130
122
|
|
|
131
123
|
Useful `init` options: `--from <path>`, `--mode quick|full`, `--no-context`, `--project-name`,
|
|
132
124
|
`--language` (of the generated documents), `--agents claude,codex,...`, `--agent claude|none`,
|
|
133
|
-
`--provider github|gitlab|none
|
|
125
|
+
`--provider github|gitlab|none`, `--ai-coauthor keep|remove` and `--models recommended|inherit`.
|
|
134
126
|
|
|
135
127
|
## What it adds to your repository
|
|
136
128
|
|
|
137
129
|
```
|
|
138
130
|
.project-kit/ config.yml, state.json, workflow.yml, templates/, handoff/
|
|
139
131
|
.claude/skills/pk-*/ the skills (.agents/skills for the other agents)
|
|
132
|
+
.claude/agents/pk-* the subagents (Claude Code only)
|
|
133
|
+
.project-kit/scripts/ scripts saved with /pk-automate (catalog: .project-kit/scripts.yml)
|
|
140
134
|
docs/project/ brief.md, roadmap.md (created by the phases)
|
|
141
135
|
docs/adr/ one file per decision (created by the phases)
|
|
142
136
|
.githooks/commit-msg only if you removed the AI co-author
|
|
@@ -148,8 +142,8 @@ date and `pjkit upgrade --apply` brings it up to date without touching what you
|
|
|
148
142
|
|
|
149
143
|
## Documentation
|
|
150
144
|
|
|
151
|
-
`pjkit docs` lists every guide: `quickstart`, `example`, `session`, `flow`, `quiet`, `
|
|
152
|
-
`provisioning`, `handoff`, `backlog-schema`, `skills`, `statusline` and `agents`.
|
|
145
|
+
`pjkit docs` lists every guide: `quickstart`, `example`, `session`, `flow`, `quiet`, `automate`,
|
|
146
|
+
`upgrade`, `provisioning`, `handoff`, `backlog-schema`, `skills`, `statusline` and `agents`.
|
|
153
147
|
|
|
154
148
|
## License
|
|
155
149
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "project-kit-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "Project definition and dev-workflow kit that hands off to Spec Kit"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -27,6 +27,10 @@ dependencies = ["pyyaml>=6.0.3"]
|
|
|
27
27
|
[[project.authors]]
|
|
28
28
|
name = "Miguel Tomás"
|
|
29
29
|
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://miguelmatg.github.io/project-kit-site/"
|
|
32
|
+
Documentation = "https://miguelmatg.github.io/project-kit-site/"
|
|
33
|
+
|
|
30
34
|
[project.scripts]
|
|
31
35
|
pjkit = "project_kit.entry:main"
|
|
32
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "project-kit-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
description = "Project definition and dev-workflow kit that hands off to Spec Kit"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -21,6 +21,10 @@ dependencies = [
|
|
|
21
21
|
"pyyaml>=6.0.3",
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://miguelmatg.github.io/project-kit-site/"
|
|
26
|
+
Documentation = "https://miguelmatg.github.io/project-kit-site/"
|
|
27
|
+
|
|
24
28
|
[project.scripts]
|
|
25
29
|
pjkit = "project_kit.entry:main"
|
|
26
30
|
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""The memory of actions: notice when the AI writes the same code again and again.
|
|
2
|
+
|
|
3
|
+
An agent asked for the same kind of thing (resize an image, read a web page, convert a file)
|
|
4
|
+
writes much the same script every time, and every time pays for it in tokens. Claude Code calls
|
|
5
|
+
``pjkit hook post-tool-use`` after each Bash, PowerShell and Write call (see ``claude_hooks``);
|
|
6
|
+
this module keeps, for every piece of code that looks like a script, what it uses: the modules
|
|
7
|
+
it imports, the functions and methods it calls, the commands and long options it runs. Two
|
|
8
|
+
pieces of code that use much the same are the same action, even when the model named its
|
|
9
|
+
variables differently or changed the URL, the path or the size this time. When a group
|
|
10
|
+
has been written ``REPEATS`` times, the hook tells the agent once, and the ``pk-automate`` skill
|
|
11
|
+
offers the user to save it as a script. What the user decided is remembered: a declined group is
|
|
12
|
+
never offered again, a saved one is listed at the start of each session so the agent runs it
|
|
13
|
+
instead of writing it.
|
|
14
|
+
|
|
15
|
+
The memory is local (``.project-kit/actions/``, ignored by git). The scripts and their catalog
|
|
16
|
+
(``.project-kit/scripts/`` and ``.project-kit/scripts.yml``) are the project's and are versioned.
|
|
17
|
+
|
|
18
|
+
The hook runs after every command, so this module imports nothing heavy: no yaml, no config.
|
|
19
|
+
Two hooks running at once can lose one occurrence; a count that is one short does no harm.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from project_kit.fsutil import atomic_write_text
|
|
31
|
+
|
|
32
|
+
PROJECT_DIR = ".project-kit" # config.PROJECT_DIR, not imported: config needs yaml
|
|
33
|
+
MEMORY = Path(PROJECT_DIR) / "actions" / "memory.json"
|
|
34
|
+
SCRIPTS_DIR = Path(PROJECT_DIR) / "scripts"
|
|
35
|
+
CATALOG = Path(PROJECT_DIR) / "scripts.yml"
|
|
36
|
+
HOOK_COMMAND = "pjkit hook post-tool-use"
|
|
37
|
+
|
|
38
|
+
# How many times something is written before it is offered as a script.
|
|
39
|
+
REPEATS = 3
|
|
40
|
+
# The same thing again in the same session within this time is a retry, not a new occurrence.
|
|
41
|
+
RETRY_SECONDS = 300
|
|
42
|
+
# How alike two pieces of code must be to be "the same action": the share of what they use in
|
|
43
|
+
# common (Jaccard). Code that uses fewer than MIN_FEATURES things only matches its exact shape.
|
|
44
|
+
SIMILAR = 0.6
|
|
45
|
+
MIN_FEATURES = 4
|
|
46
|
+
MAX_GROUPS = 300
|
|
47
|
+
MAX_EXAMPLE = 4000
|
|
48
|
+
|
|
49
|
+
NEW, OFFERED, SAVED, DECLINED = "new", "offered", "saved", "declined"
|
|
50
|
+
STATUSES = (NEW, OFFERED, SAVED, DECLINED)
|
|
51
|
+
|
|
52
|
+
_SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bash", ".ps1", ".rb", ".pl"}
|
|
53
|
+
_SCRATCH_PARTS = {"tmp", "temp", "scratch", "scratchpad"}
|
|
54
|
+
_INLINE = re.compile(
|
|
55
|
+
r"(?:^|[\s;&|(])(?:python3?|py|uv run(?: --\S+)* python3?|node|deno|ruby|perl|php|bun)"
|
|
56
|
+
r"(?:\.exe)?\s+(?:-c|-e|--eval|eval|-)(?:\s|$)|<<-?\s*['\"]?\w+|@['\"]\s*$",
|
|
57
|
+
re.MULTILINE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
|
|
61
|
+
_STRING = re.compile(
|
|
62
|
+
r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'|"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\''
|
|
63
|
+
)
|
|
64
|
+
_COMMENT = re.compile(r"(?m)^\s*(?:#|//|REM\b).*$")
|
|
65
|
+
_PATH = re.compile(r"(?<![\w.])(?:[A-Za-z]:)?(?:\.{0,2}[/\\])[\w.@~+-]+(?:[/\\][\w.@~+-]*)*")
|
|
66
|
+
_NUMBER = re.compile(r"\b\d+(?:\.\d+)?\b")
|
|
67
|
+
_TOKEN = re.compile(r"\w+|[^\w\s]")
|
|
68
|
+
|
|
69
|
+
# What a piece of code uses, read from the code itself: no parser, so it works for any language.
|
|
70
|
+
_IMPORT = re.compile(
|
|
71
|
+
r"""\bimport\s+([\w.]+(?:\s*,\s*[\w.]+)*)|\bfrom\s+([\w.]+)\s+import"""
|
|
72
|
+
r"""|require\(\s*['"]([^'"]+)|\bfrom\s+['"]([^'"]+)['"]|Import-Module\s+([\w.-]+)"""
|
|
73
|
+
)
|
|
74
|
+
_ATTR = re.compile(r"\.\s*([A-Za-z_]\w*)")
|
|
75
|
+
_CALL = re.compile(r"\b([A-Za-z_][\w-]*)\s*\(")
|
|
76
|
+
_PIPED = re.compile(r"(?:^|\||&&|;|\$\()\s*([a-z][\w-]*)")
|
|
77
|
+
_FLAG = re.compile(r"(?<![\w-])(--[a-z][\w-]*)")
|
|
78
|
+
# Used by nearly every script, so they say nothing about what this one does.
|
|
79
|
+
_COMMON = frozenset(
|
|
80
|
+
"print len open str int float bool list dict set tuple range enumerate isinstance sorted "
|
|
81
|
+
"format join split strip append extend items keys values read write close exit main self "
|
|
82
|
+
"import from if for while in not and or return with def class function const let var echo "
|
|
83
|
+
"cd cat python python3 py node uv run".split()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Group:
|
|
89
|
+
id: str
|
|
90
|
+
kind: str # bash | write
|
|
91
|
+
count: int
|
|
92
|
+
first: str
|
|
93
|
+
last: str
|
|
94
|
+
session: str
|
|
95
|
+
status: str = NEW
|
|
96
|
+
script: str = "" # name in the catalog, once saved
|
|
97
|
+
example: str = "" # the latest occurrence, without secrets, cut at MAX_EXAMPLE
|
|
98
|
+
shape: str = "" # hash of the normalized code
|
|
99
|
+
features: list[str] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Memory:
|
|
104
|
+
groups: list[Group] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
def find(self, group_id: str) -> Group | None:
|
|
107
|
+
return next((g for g in self.groups if g.id == group_id), None)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Action:
|
|
112
|
+
kind: str
|
|
113
|
+
code: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def project_root(start: Path) -> Path | None:
|
|
117
|
+
for folder in (start, *start.parents):
|
|
118
|
+
if (folder / PROJECT_DIR / "config.yml").is_file():
|
|
119
|
+
return folder
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- what counts as code ---------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _lines(text: str) -> int:
|
|
127
|
+
return sum(1 for line in text.splitlines() if line.strip())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_scratch(path: Path, root: Path) -> bool:
|
|
131
|
+
"""A script written outside the project or in a temporary folder: a throwaway, not the
|
|
132
|
+
project's own code (which is edited over and over on purpose)."""
|
|
133
|
+
try:
|
|
134
|
+
rel = path.resolve().relative_to(root.resolve())
|
|
135
|
+
except ValueError:
|
|
136
|
+
return True
|
|
137
|
+
return any(part.lower() in _SCRATCH_PARTS for part in rel.parts[:-1])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def extract(payload: dict[str, Any], root: Path) -> Action | None:
|
|
141
|
+
"""The code in a tool call, if it looks like a script someone may want to keep."""
|
|
142
|
+
tool = payload.get("tool_name")
|
|
143
|
+
data = payload.get("tool_input")
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return None
|
|
146
|
+
if tool in ("Bash", "PowerShell"):
|
|
147
|
+
code = data.get("command")
|
|
148
|
+
if not isinstance(code, str):
|
|
149
|
+
return None
|
|
150
|
+
stripped = code.strip()
|
|
151
|
+
if stripped.startswith("pjkit") or SCRIPTS_DIR.as_posix() in code.replace("\\", "/"):
|
|
152
|
+
return None # already one of ours
|
|
153
|
+
inline = bool(_INLINE.search(code)) and len(stripped) >= 80
|
|
154
|
+
return Action("bash", code) if inline or _lines(code) >= 4 else None
|
|
155
|
+
if tool == "Write":
|
|
156
|
+
path, code = data.get("file_path"), data.get("content")
|
|
157
|
+
if not (isinstance(path, str) and isinstance(code, str)):
|
|
158
|
+
return None
|
|
159
|
+
target = Path(path)
|
|
160
|
+
if target.suffix.lower() not in _SCRIPT_SUFFIXES or _lines(code) < 5:
|
|
161
|
+
return None
|
|
162
|
+
if SCRIPTS_DIR.as_posix() in path.replace("\\", "/"):
|
|
163
|
+
return None
|
|
164
|
+
return Action("write", code) if _is_scratch(target, root) else None
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- what "the same" means -------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def normalize(code: str) -> str:
|
|
172
|
+
"""The shape of the code: the values that change from one request to the next are gone."""
|
|
173
|
+
text = _URL.sub(" U ", code)
|
|
174
|
+
text = _STRING.sub(" S ", text)
|
|
175
|
+
text = _COMMENT.sub("", text)
|
|
176
|
+
text = _PATH.sub(" P ", text)
|
|
177
|
+
text = _NUMBER.sub("0", text)
|
|
178
|
+
return " ".join(_TOKEN.findall(text))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def shape_hash(code: str) -> str:
|
|
182
|
+
return hashlib.sha1(normalize(code).encode()).hexdigest()[:8]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def features(code: str) -> list[str]:
|
|
186
|
+
"""What the code uses: modules, called functions and methods, commands, long options."""
|
|
187
|
+
found = set()
|
|
188
|
+
for match in _IMPORT.finditer(code):
|
|
189
|
+
names = next(g for g in match.groups() if g)
|
|
190
|
+
for name in names.split(","):
|
|
191
|
+
found.add("import:" + re.split(r"[./]", name.strip().lstrip("@"))[0])
|
|
192
|
+
body = _COMMENT.sub("", _STRING.sub(" S ", _URL.sub(" U ", code)))
|
|
193
|
+
first = body.strip().splitlines()[0] if body.strip() else ""
|
|
194
|
+
found |= {"." + name for name in _ATTR.findall(body)}
|
|
195
|
+
found |= {name + "()" for name in _CALL.findall(body)}
|
|
196
|
+
found |= {"$" + name for name in _PIPED.findall(first)}
|
|
197
|
+
found |= set(_FLAG.findall(body))
|
|
198
|
+
return sorted(f for f in found if f.split(":")[-1].strip(".()$").lower() not in _COMMON)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def similarity(a: list[str], b: list[str]) -> float:
|
|
202
|
+
sa, sb = set(a), set(b)
|
|
203
|
+
if len(sa) < MIN_FEATURES or len(sb) < MIN_FEATURES:
|
|
204
|
+
return 0.0
|
|
205
|
+
return len(sa & sb) / len(sa | sb)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _same(group: "Group", shape: str, uses: list[str]) -> float:
|
|
209
|
+
return 1.0 if group.shape == shape else similarity(uses, group.features)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- the memory ------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load(root: Path) -> Memory:
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads((root / MEMORY).read_text(encoding="utf-8"))
|
|
218
|
+
groups = [Group(**g) for g in data["groups"]]
|
|
219
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
220
|
+
return Memory() # missing or damaged: start again, it is only a memory
|
|
221
|
+
return Memory([g for g in groups if g.status in STATUSES])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def save(root: Path, memory: Memory) -> None:
|
|
225
|
+
folder = root / MEMORY.parent
|
|
226
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
ignore = folder / ".gitignore"
|
|
228
|
+
if not ignore.exists():
|
|
229
|
+
ignore.write_text("*\n", encoding="utf-8")
|
|
230
|
+
if len(memory.groups) > MAX_GROUPS: # forget what was seen once, oldest first
|
|
231
|
+
once = sorted((g for g in memory.groups if g.count == 1 and g.status == NEW),
|
|
232
|
+
key=lambda g: g.last) # fmt: skip
|
|
233
|
+
drop = {id(g) for g in once[: len(memory.groups) - MAX_GROUPS]}
|
|
234
|
+
memory.groups = [g for g in memory.groups if id(g) not in drop]
|
|
235
|
+
body = {"schema_version": 1, "groups": [asdict(g) for g in memory.groups]}
|
|
236
|
+
atomic_write_text(root / MEMORY, json.dumps(body, ensure_ascii=False) + "\n")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _redact(text: str) -> str:
|
|
240
|
+
from project_kit.redact import redact # only when something is recorded
|
|
241
|
+
|
|
242
|
+
return redact(text)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def record(root: Path, action: Action, session: str, now: datetime) -> tuple[Group, bool]:
|
|
246
|
+
"""Add one occurrence. Returns its group and whether it has just become worth offering
|
|
247
|
+
(it then counts as offered, so it is offered once)."""
|
|
248
|
+
memory = load(root)
|
|
249
|
+
shape, uses = shape_hash(action.code), features(action.code)
|
|
250
|
+
stamp = now.isoformat(timespec="seconds")
|
|
251
|
+
# Any kind: the same script may be written to a file one day and run inline the next.
|
|
252
|
+
best = max(memory.groups, key=lambda g: _same(g, shape, uses), default=None)
|
|
253
|
+
if best is None or _same(best, shape, uses) < SIMILAR:
|
|
254
|
+
best = Group(shape, action.kind, 0, stamp, stamp, session, shape=shape, features=uses)
|
|
255
|
+
while memory.find(best.id): # same shape as a group that grew apart from it: rare
|
|
256
|
+
best.id = hashlib.sha1(best.id.encode()).hexdigest()[:8]
|
|
257
|
+
memory.groups.append(best)
|
|
258
|
+
retry = (
|
|
259
|
+
best.count > 0
|
|
260
|
+
and best.session == session
|
|
261
|
+
and (now - datetime.fromisoformat(best.last)).total_seconds() < RETRY_SECONDS
|
|
262
|
+
)
|
|
263
|
+
if not retry:
|
|
264
|
+
best.count += 1
|
|
265
|
+
best.last, best.session = stamp, session
|
|
266
|
+
best.example = _redact(action.code)[:MAX_EXAMPLE]
|
|
267
|
+
offer = best.status == NEW and best.count >= REPEATS
|
|
268
|
+
if offer:
|
|
269
|
+
best.status = OFFERED
|
|
270
|
+
save(root, memory)
|
|
271
|
+
return best, offer
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def notice(group: Group) -> str:
|
|
275
|
+
return (
|
|
276
|
+
f"project-kit: this code (action {group.id}) has now been written {group.count} times "
|
|
277
|
+
"in this project. When the user's current request is done, follow the pk-automate skill "
|
|
278
|
+
"to offer to save it as a script, so that next time it is run instead of written again."
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def post_tool_use(payload: dict[str, Any], cwd: Path, now: datetime | None = None) -> str:
|
|
283
|
+
"""What the hook prints: Claude Code's JSON with a notice, or nothing. Never raises."""
|
|
284
|
+
try:
|
|
285
|
+
root = project_root(cwd)
|
|
286
|
+
action = extract(payload, root) if root else None
|
|
287
|
+
if root is None or action is None:
|
|
288
|
+
return ""
|
|
289
|
+
session = str(payload.get("session_id") or "")
|
|
290
|
+
group, offer = record(root, action, session, now or datetime.now(UTC))
|
|
291
|
+
if not offer:
|
|
292
|
+
return ""
|
|
293
|
+
except Exception: # noqa: BLE001 - a hook must never break the agent's work
|
|
294
|
+
return ""
|
|
295
|
+
return json.dumps(
|
|
296
|
+
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": notice(group)}}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- what the user decided -------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class ActionsError(ValueError):
|
|
304
|
+
"""An unknown action, a missing script, a bad catalog."""
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def set_status(root: Path, group_id: str, status: str, script: str = "") -> Group:
|
|
308
|
+
memory = load(root)
|
|
309
|
+
group = memory.find(group_id)
|
|
310
|
+
if group is None:
|
|
311
|
+
raise ActionsError(f"No action {group_id!r}; `pjkit actions list --all` shows them.")
|
|
312
|
+
group.status, group.script = status, script
|
|
313
|
+
if status == NEW:
|
|
314
|
+
group.count = 0 # "not now": offered again after REPEATS more
|
|
315
|
+
save(root, memory)
|
|
316
|
+
return group
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def pending(memory: Memory) -> list[Group]:
|
|
320
|
+
return [g for g in memory.groups if g.status == OFFERED]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def repeated(memory: Memory, *, everything: bool = False) -> list[Group]:
|
|
324
|
+
shown = [g for g in memory.groups if everything or g.count >= 2 or g.status != NEW]
|
|
325
|
+
return sorted(shown, key=lambda g: (-g.count, g.last))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def summary(group: Group, width: int = 60) -> str:
|
|
329
|
+
"""What the code uses, which says more than its first line (often ``python - <<EOF``)."""
|
|
330
|
+
modules = [f[7:] for f in group.features if f.startswith("import:")]
|
|
331
|
+
calls = [f[:-2] for f in group.features if f.endswith("()")]
|
|
332
|
+
text = ", ".join(modules) + (": " if modules and calls else "") + ", ".join(calls)
|
|
333
|
+
if not text:
|
|
334
|
+
text = next((x.strip() for x in group.example.splitlines() if x.strip()), "")
|
|
335
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_list(groups: list[Group]) -> str:
|
|
339
|
+
if not groups:
|
|
340
|
+
return "Nothing has been written more than once yet."
|
|
341
|
+
lines = [f"{'id':<9} {'times':>5} {'status':<9} {'kind':<6} uses"]
|
|
342
|
+
for g in groups:
|
|
343
|
+
status = f"{g.status}:{g.script}" if g.status == SAVED else g.status
|
|
344
|
+
lines.append(f"{g.id:<9} {g.count:>5} {status:<9} {g.kind:<6} {summary(g)}")
|
|
345
|
+
lines.append(
|
|
346
|
+
"\n`pjkit actions show <id>` shows the code. The pk-automate skill turns one into a script."
|
|
347
|
+
)
|
|
348
|
+
return "\n".join(lines)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def render_group(group: Group) -> str:
|
|
352
|
+
head = (
|
|
353
|
+
f"Action {group.id}: {group.kind}, written {group.count} time(s), {group.status}"
|
|
354
|
+
+ (f" (script {group.script})" if group.script else "")
|
|
355
|
+
+ f"; first {group.first}, last {group.last}."
|
|
356
|
+
)
|
|
357
|
+
return f"{head}\nThe latest one (secrets removed):\n\n{group.example}"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# --- the catalog of saved scripts ------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class Script:
|
|
365
|
+
name: str
|
|
366
|
+
file: str # relative to SCRIPTS_DIR
|
|
367
|
+
description: str
|
|
368
|
+
usage: str
|
|
369
|
+
action: str = "" # the group it came from, if any
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,47}")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def load_catalog(root: Path) -> list[Script]:
|
|
376
|
+
import yaml # not at module level: the hook path must stay light
|
|
377
|
+
|
|
378
|
+
path = root / CATALOG
|
|
379
|
+
if not path.exists():
|
|
380
|
+
return []
|
|
381
|
+
try:
|
|
382
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
383
|
+
except yaml.YAMLError as exc:
|
|
384
|
+
raise ActionsError(f"scripts.yml is not valid YAML: {exc}") from exc
|
|
385
|
+
entries = data.get("scripts", []) if isinstance(data, dict) else None
|
|
386
|
+
if not isinstance(entries, list):
|
|
387
|
+
raise ActionsError("scripts.yml must have a list under `scripts`")
|
|
388
|
+
scripts = []
|
|
389
|
+
for entry in entries:
|
|
390
|
+
if not isinstance(entry, dict) or not all(
|
|
391
|
+
isinstance(entry.get(k), str) for k in ("name", "file", "description", "usage")
|
|
392
|
+
):
|
|
393
|
+
raise ActionsError("every script in scripts.yml needs name, file, description, usage")
|
|
394
|
+
scripts.append(Script(**{k: str(entry.get(k, "")) for k in Script.__dataclass_fields__}))
|
|
395
|
+
return scripts
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def save_catalog(root: Path, scripts: list[Script]) -> None:
|
|
399
|
+
import yaml
|
|
400
|
+
|
|
401
|
+
body = {
|
|
402
|
+
"schema_version": 1,
|
|
403
|
+
"scripts": [{k: v for k, v in asdict(s).items() if v} for s in scripts],
|
|
404
|
+
}
|
|
405
|
+
header = (
|
|
406
|
+
"# Scripts saved so the AI runs them instead of writing the same code again.\n"
|
|
407
|
+
"# Kept by `pjkit actions save`; safe to edit by hand.\n"
|
|
408
|
+
)
|
|
409
|
+
text = yaml.safe_dump(body, sort_keys=False, allow_unicode=True, width=100)
|
|
410
|
+
atomic_write_text(root / CATALOG, header + text)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def add_script(root: Path, script: Script) -> None:
|
|
414
|
+
"""Add (or replace, by name) a script in the catalog; mark its action as saved."""
|
|
415
|
+
if not _NAME.fullmatch(script.name):
|
|
416
|
+
raise ActionsError(f"{script.name!r}: use lowercase letters, digits and dashes")
|
|
417
|
+
file = (root / SCRIPTS_DIR / script.file).resolve()
|
|
418
|
+
if not file.is_relative_to((root / SCRIPTS_DIR).resolve()) or not file.is_file():
|
|
419
|
+
raise ActionsError(f"{(SCRIPTS_DIR / script.file).as_posix()} does not exist")
|
|
420
|
+
if not script.description.strip() or not script.usage.strip():
|
|
421
|
+
raise ActionsError("a script needs a description and a usage line")
|
|
422
|
+
if script.action:
|
|
423
|
+
set_status(root, script.action, SAVED, script.name)
|
|
424
|
+
scripts = [s for s in load_catalog(root) if s.name != script.name] + [script]
|
|
425
|
+
save_catalog(root, sorted(scripts, key=lambda s: s.name))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def render_catalog(scripts: list[Script]) -> str:
|
|
429
|
+
if not scripts:
|
|
430
|
+
return "No scripts saved yet."
|
|
431
|
+
lines = []
|
|
432
|
+
for s in scripts:
|
|
433
|
+
lines += [f"{s.name}: {s.description}", f" {s.usage}"]
|
|
434
|
+
return "\n".join(lines)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def session_lines(root: Path, limit: int = 12) -> list[str]:
|
|
438
|
+
"""What the start of a session needs to know: the saved scripts and what awaits a decision."""
|
|
439
|
+
lines = []
|
|
440
|
+
try:
|
|
441
|
+
scripts = load_catalog(root)
|
|
442
|
+
except ActionsError:
|
|
443
|
+
scripts = []
|
|
444
|
+
if scripts:
|
|
445
|
+
lines.append(
|
|
446
|
+
"Saved scripts: before writing code for one of these, run the script instead "
|
|
447
|
+
"(usage: `pjkit actions scripts`; in Claude Code, through the pk-runner subagent):"
|
|
448
|
+
)
|
|
449
|
+
lines += [f"- {s.name}: {s.description}" for s in scripts[:limit]]
|
|
450
|
+
if len(scripts) > limit:
|
|
451
|
+
lines.append(f"- and {len(scripts) - limit} more.")
|
|
452
|
+
waiting = pending(load(root))
|
|
453
|
+
if waiting:
|
|
454
|
+
ids = ", ".join(g.id for g in waiting)
|
|
455
|
+
lines.append(
|
|
456
|
+
f"Code written again and again is waiting for the user's decision ({ids}): at a "
|
|
457
|
+
"natural pause, follow the pk-automate skill."
|
|
458
|
+
)
|
|
459
|
+
return lines
|