langchef 0.1.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.
Files changed (134) hide show
  1. langchef-0.1.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
  2. langchef-0.1.0/.github/ISSUE_TEMPLATE/decision.yml +51 -0
  3. langchef-0.1.0/.github/ISSUE_TEMPLATE/defect.yml +52 -0
  4. langchef-0.1.0/.github/ISSUE_TEMPLATE/feature.yml +66 -0
  5. langchef-0.1.0/.github/pull_request_template.md +18 -0
  6. langchef-0.1.0/.github/workflows/ci.yml +38 -0
  7. langchef-0.1.0/.github/workflows/claim.yml +97 -0
  8. langchef-0.1.0/.github/workflows/pr-guard.yml +119 -0
  9. langchef-0.1.0/.github/workflows/release.yml +106 -0
  10. langchef-0.1.0/.gitignore +14 -0
  11. langchef-0.1.0/.python-version +1 -0
  12. langchef-0.1.0/AGENTS.md +270 -0
  13. langchef-0.1.0/CONTRIBUTING.md +65 -0
  14. langchef-0.1.0/DECISIONS.md +192 -0
  15. langchef-0.1.0/LICENSE +202 -0
  16. langchef-0.1.0/NON-GOALS.md +77 -0
  17. langchef-0.1.0/PKG-INFO +446 -0
  18. langchef-0.1.0/README.md +422 -0
  19. langchef-0.1.0/TRACKER.md +124 -0
  20. langchef-0.1.0/adapters/README.md +9 -0
  21. langchef-0.1.0/adapters/claude-code/.claude-plugin/plugin.json +10 -0
  22. langchef-0.1.0/adapters/claude-code/README.md +37 -0
  23. langchef-0.1.0/adapters/claude-code/commands/langchef-calibrate.md +25 -0
  24. langchef-0.1.0/adapters/claude-code/commands/langchef-design.md +20 -0
  25. langchef-0.1.0/adapters/claude-code/commands/langchef-experiment.md +17 -0
  26. langchef-0.1.0/adapters/claude-code/skills/langchef-eval/SKILL.md +141 -0
  27. langchef-0.1.0/docs/.nojekyll +0 -0
  28. langchef-0.1.0/docs/AGENT-CONTRACT.md +91 -0
  29. langchef-0.1.0/docs/RESERVE-NAMES.md +76 -0
  30. langchef-0.1.0/docs/byod.html +188 -0
  31. langchef-0.1.0/docs/cli.html +182 -0
  32. langchef-0.1.0/docs/concepts.html +219 -0
  33. langchef-0.1.0/docs/index.html +280 -0
  34. langchef-0.1.0/docs/integrations.html +147 -0
  35. langchef-0.1.0/docs/numbers.html +400 -0
  36. langchef-0.1.0/docs/quickstart.html +23 -0
  37. langchef-0.1.0/docs/ref-agreement.html +387 -0
  38. langchef-0.1.0/docs/ref-compare.html +368 -0
  39. langchef-0.1.0/docs/ref-design.html +413 -0
  40. langchef-0.1.0/docs/ref-judging.html +271 -0
  41. langchef-0.1.0/docs/ref-sampling.html +227 -0
  42. langchef-0.1.0/docs/ref-taxonomy.html +257 -0
  43. langchef-0.1.0/docs/search-index.json +944 -0
  44. langchef-0.1.0/docs/search.js +59 -0
  45. langchef-0.1.0/docs/start.html +387 -0
  46. langchef-0.1.0/docs/style.css +277 -0
  47. langchef-0.1.0/dogfood/README.md +237 -0
  48. langchef-0.1.0/dogfood/__init__.py +6 -0
  49. langchef-0.1.0/dogfood/app.py +446 -0
  50. langchef-0.1.0/dogfood/build.py +102 -0
  51. langchef-0.1.0/dogfood/corpus.py +409 -0
  52. langchef-0.1.0/dogfood/label.py +51 -0
  53. langchef-0.1.0/packs/classification/README.md +43 -0
  54. langchef-0.1.0/packs/classification/metrics.py +143 -0
  55. langchef-0.1.0/packs/classification/pack.toml +37 -0
  56. langchef-0.1.0/packs/classification/rubrics/README.md +28 -0
  57. langchef-0.1.0/packs/genai-rag/pack.toml +39 -0
  58. langchef-0.1.0/packs/genai-rag/rubrics/README.md +15 -0
  59. langchef-0.1.0/pyproject.toml +85 -0
  60. langchef-0.1.0/scripts/assert_no_credentials.py +27 -0
  61. langchef-0.1.0/scripts/build_docs.py +3255 -0
  62. langchef-0.1.0/scripts/render_contract.py +120 -0
  63. langchef-0.1.0/scripts/verify.sh +61 -0
  64. langchef-0.1.0/src/langchef/__init__.py +7 -0
  65. langchef-0.1.0/src/langchef/cli/__init__.py +1 -0
  66. langchef-0.1.0/src/langchef/cli/calibrate_cmd.py +476 -0
  67. langchef-0.1.0/src/langchef/cli/common.py +112 -0
  68. langchef-0.1.0/src/langchef/cli/design_cmd.py +468 -0
  69. langchef-0.1.0/src/langchef/cli/experiment_cmd.py +314 -0
  70. langchef-0.1.0/src/langchef/cli/judge_cmd.py +149 -0
  71. langchef-0.1.0/src/langchef/cli/main.py +209 -0
  72. langchef-0.1.0/src/langchef/cli/memo_cmd.py +126 -0
  73. langchef-0.1.0/src/langchef/cli/power_cmd.py +122 -0
  74. langchef-0.1.0/src/langchef/cli/workspace_cmd.py +90 -0
  75. langchef-0.1.0/src/langchef/connect/__init__.py +1 -0
  76. langchef-0.1.0/src/langchef/core/__init__.py +7 -0
  77. langchef-0.1.0/src/langchef/core/agreement.py +246 -0
  78. langchef-0.1.0/src/langchef/core/compare.py +568 -0
  79. langchef-0.1.0/src/langchef/core/contract.py +200 -0
  80. langchef-0.1.0/src/langchef/core/credentials.py +27 -0
  81. langchef-0.1.0/src/langchef/core/delta.py +548 -0
  82. langchef-0.1.0/src/langchef/core/design.py +411 -0
  83. langchef-0.1.0/src/langchef/core/emit.py +32 -0
  84. langchef-0.1.0/src/langchef/core/exits.py +29 -0
  85. langchef-0.1.0/src/langchef/core/gates.py +93 -0
  86. langchef-0.1.0/src/langchef/core/retrieval.py +95 -0
  87. langchef-0.1.0/src/langchef/core/sampling.py +140 -0
  88. langchef-0.1.0/src/langchef/core/taxonomy.py +213 -0
  89. langchef-0.1.0/src/langchef/judge/__init__.py +1 -0
  90. langchef-0.1.0/src/langchef/judge/cache.py +100 -0
  91. langchef-0.1.0/src/langchef/judge/example.py +41 -0
  92. langchef-0.1.0/src/langchef/judge/providers.py +534 -0
  93. langchef-0.1.0/src/langchef/judge/rubric.py +62 -0
  94. langchef-0.1.0/src/langchef/judge/runner.py +181 -0
  95. langchef-0.1.0/src/langchef/packs/__init__.py +39 -0
  96. langchef-0.1.0/src/langchef/packs/loader.py +152 -0
  97. langchef-0.1.0/src/langchef/packs/manifest.py +243 -0
  98. langchef-0.1.0/src/langchef/render/__init__.py +1 -0
  99. langchef-0.1.0/src/langchef/render/memo.py +189 -0
  100. langchef-0.1.0/src/langchef/workspace/__init__.py +1 -0
  101. langchef-0.1.0/src/langchef/workspace/config.py +117 -0
  102. langchef-0.1.0/src/langchef/workspace/dataset.py +149 -0
  103. langchef-0.1.0/src/langchef/workspace/experiments.py +181 -0
  104. langchef-0.1.0/src/langchef/workspace/formats.py +106 -0
  105. langchef-0.1.0/src/langchef/workspace/ledger.py +52 -0
  106. langchef-0.1.0/src/langchef/workspace/paths.py +111 -0
  107. langchef-0.1.0/src/langchef/workspace/runs.py +142 -0
  108. langchef-0.1.0/src/langchef/workspace/scaffold.py +117 -0
  109. langchef-0.1.0/tests/cassettes/answer-quality.replay.json +3 -0
  110. langchef-0.1.0/tests/cassettes/openai-chat-completions.json +160 -0
  111. langchef-0.1.0/tests/conftest.py +50 -0
  112. langchef-0.1.0/tests/test_agreement.py +172 -0
  113. langchef-0.1.0/tests/test_boundaries.py +317 -0
  114. langchef-0.1.0/tests/test_cli.py +91 -0
  115. langchef-0.1.0/tests/test_compare.py +428 -0
  116. langchef-0.1.0/tests/test_dataset.py +155 -0
  117. langchef-0.1.0/tests/test_delta.py +469 -0
  118. langchef-0.1.0/tests/test_design.py +214 -0
  119. langchef-0.1.0/tests/test_docs_in_sync.py +46 -0
  120. langchef-0.1.0/tests/test_dogfood.py +364 -0
  121. langchef-0.1.0/tests/test_exits.py +23 -0
  122. langchef-0.1.0/tests/test_flow.py +675 -0
  123. langchef-0.1.0/tests/test_judge.py +358 -0
  124. langchef-0.1.0/tests/test_litellm_path.py +384 -0
  125. langchef-0.1.0/tests/test_no_credentials.py +43 -0
  126. langchef-0.1.0/tests/test_pack_classification.py +227 -0
  127. langchef-0.1.0/tests/test_packs.py +310 -0
  128. langchef-0.1.0/tests/test_power.py +133 -0
  129. langchef-0.1.0/tests/test_retrieval.py +181 -0
  130. langchef-0.1.0/tests/test_sampling.py +154 -0
  131. langchef-0.1.0/tests/test_taxonomy.py +120 -0
  132. langchef-0.1.0/tests/test_waiter.py +608 -0
  133. langchef-0.1.0/tests/test_workspace.py +164 -0
  134. langchef-0.1.0/uv.lock +1503 -0
@@ -0,0 +1,8 @@
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: The agent contract
4
+ url: https://deepskandpal.github.io/LangChef/cli.html
5
+ about: What each command does, what it writes, and what the exit codes mean. Read this before filing about CLI behaviour.
6
+ - name: How the numbers work
7
+ url: https://deepskandpal.github.io/LangChef/numbers.html
8
+ about: What a verdict, an interval and a detection limit actually mean here.
@@ -0,0 +1,51 @@
1
+ name: Decision
2
+ description: A call to make, not code to write
3
+ labels: ["type:decision", "lifecycle:spec", "agent:needs-human"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Decisions are left open on purpose, with the evidence that closes them named up front, so
9
+ that none of them becomes a standing debate. Settled ones go into `DECISIONS.md` with a
10
+ date and are not reopened.
11
+ - type: textarea
12
+ id: description
13
+ attributes:
14
+ label: Description
15
+ description: The question, in one line.
16
+ validations: {required: true}
17
+ - type: textarea
18
+ id: background
19
+ attributes:
20
+ label: Background
21
+ description: Why it is open, and what it costs to get wrong.
22
+ validations: {required: true}
23
+ - type: textarea
24
+ id: options
25
+ attributes:
26
+ label: Options
27
+ value: |
28
+ ### (a)
29
+ - **For:**
30
+ - **Against:**
31
+
32
+ ### (b)
33
+ - **For:**
34
+ - **Against:**
35
+ validations: {required: true}
36
+ - type: textarea
37
+ id: default
38
+ attributes:
39
+ label: The default if nobody decides
40
+ description: |
41
+ There is always one, and it is usually the bad one — a threshold hard-coded where nobody
42
+ reviews it. Name it, so that drifting is a choice rather than an accident.
43
+ validations: {required: true}
44
+ - type: textarea
45
+ id: acceptance
46
+ attributes:
47
+ label: Acceptance criteria (closes when)
48
+ description: The evidence, the date, or the trigger. Not "when we have time".
49
+ value: |
50
+ - [ ] The call is written into `DECISIONS.md` as a new dated entry
51
+ validations: {required: true}
@@ -0,0 +1,52 @@
1
+ name: Defect
2
+ description: Behaviour that is wrong, not merely absent
3
+ labels: ["type:defect", "lifecycle:ready"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ **A wrong number is worse than a crash here, because nothing complains.** If a figure looks
9
+ wrong, this is the most valuable issue you can file.
10
+ - type: textarea
11
+ id: description
12
+ attributes:
13
+ label: Description
14
+ description: The behaviour, and what it should be instead.
15
+ validations: {required: true}
16
+ - type: textarea
17
+ id: background
18
+ attributes:
19
+ label: Background
20
+ description: How it was found, and why it matters more or less than it looks like it does.
21
+ validations: {required: true}
22
+ - type: textarea
23
+ id: repro
24
+ attributes:
25
+ label: How to see it
26
+ description: The commands. Prefer the dogfood workspace, which needs no API key.
27
+ render: shell
28
+ validations: {required: true}
29
+ - type: dropdown
30
+ id: kind
31
+ attributes:
32
+ label: What kind of wrong
33
+ options:
34
+ - A wrong number — silent, and looks like a right number
35
+ - A wrong verdict — the arithmetic is right, the conclusion is not
36
+ - An integrity gap — the result can be manufactured
37
+ - A crash or an error
38
+ - An inconvenience
39
+ validations: {required: true}
40
+ - type: textarea
41
+ id: acceptance
42
+ attributes:
43
+ label: Acceptance criteria
44
+ value: |
45
+ - [ ] A test that fails before the fix and passes after
46
+ - [ ] `./scripts/verify.sh` stays green (10/10)
47
+ validations: {required: true}
48
+ - type: input
49
+ id: start
50
+ attributes:
51
+ label: Start at
52
+ placeholder: src/langchef/judge/runner.py
@@ -0,0 +1,66 @@
1
+ name: Feature
2
+ description: A capability that does not exist yet
3
+ labels: ["type:feature", "lifecycle:spec"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Write this so somebody — or something — can pick it up cold, months from now, with no
9
+ memory of this conversation. That is the whole standard. See any existing issue for the
10
+ shape; [#28](https://github.com/deepskandpal/LangChef/issues/28) is a good example.
11
+ - type: textarea
12
+ id: description
13
+ attributes:
14
+ label: Description
15
+ description: One or two sentences, imperative. The capability, not the implementation.
16
+ validations: {required: true}
17
+ - type: textarea
18
+ id: background
19
+ attributes:
20
+ label: Background
21
+ description: |
22
+ Why this exists and what is worse without it. Where it came from — a build-order section,
23
+ a defect, a conversation. Be concrete: "it would be nice" is not a reason.
24
+ validations: {required: true}
25
+ - type: textarea
26
+ id: dependencies
27
+ attributes:
28
+ label: Dependencies
29
+ value: |
30
+ - **Blocked by:** #
31
+ - **Blocks:** #
32
+ - **Related:** #
33
+ - **Needs a person:**
34
+ validations: {required: true}
35
+ - type: textarea
36
+ id: acceptance
37
+ attributes:
38
+ label: Acceptance criteria
39
+ description: |
40
+ Observable and checkable. Include the ways this could be built *wrongly* — a silently
41
+ dropped row, a threshold that stops being declared, an interval that stops matching its
42
+ point estimate. Those are the criteria that earn their place.
43
+ value: |
44
+ - [ ]
45
+ - [ ] `./scripts/verify.sh` stays green (10/10)
46
+ validations: {required: true}
47
+ - type: textarea
48
+ id: notes
49
+ attributes:
50
+ label: Implementation notes
51
+ description: Start at which file, what constrains it, what has already gone wrong nearby.
52
+ value: |
53
+ **Start at** `src/langchef/`
54
+ validations: {required: true}
55
+ - type: textarea
56
+ id: scope
57
+ attributes:
58
+ label: Out of scope
59
+ description: What this is not. Build order §10 lists things that are never built — check it.
60
+ - type: dropdown
61
+ id: area
62
+ attributes:
63
+ label: Area
64
+ description: Area labels are collision boundaries. Two agents must not hold the same one.
65
+ options: [core, judge, workspace, cli, connect, packs, adapters, dogfood, docs, ci]
66
+ validations: {required: true}
@@ -0,0 +1,18 @@
1
+ Closes #
2
+
3
+ ## What changed
4
+
5
+ <!-- One paragraph. What a reviewer needs to know before reading the diff. -->
6
+
7
+ ## Checks
8
+
9
+ - [ ] `./scripts/verify.sh` — all 10 steps pass locally
10
+ - [ ] Any new statistic has a known-answer test against an independent implementation
11
+ - [ ] Generated files were regenerated, not hand-edited (`docs/*.html`, `docs/AGENT-CONTRACT.md`)
12
+ - [ ] No provider SDK is imported outside `src/langchef/judge/providers.py`
13
+ - [ ] If a rubric-scoring check changed, `VERSION` in `providers.py` was bumped
14
+ - [ ] If this settles a decision, `DECISIONS.md` has a new dated entry
15
+
16
+ ## What I did not do
17
+
18
+ <!-- Scope left out, and why. An honest gap here is worth more than a tidy diff. -->
@@ -0,0 +1,38 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test:
15
+ name: verify (python ${{ matrix.python }})
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ # Both legs always report. A break on one version should not hide the
19
+ # result on the other, which is the whole reason for testing two.
20
+ fail-fast: false
21
+ matrix:
22
+ # The range pyproject declares: requires-python = ">=3.12,<3.14".
23
+ # Advertising support for a version nothing runs is a claim, not a fact.
24
+ python: ["3.12", "3.13"]
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@v5
30
+ with:
31
+ enable-cache: true
32
+
33
+ # Every check lives in scripts/verify.sh so that what runs here and what
34
+ # a person runs locally cannot drift apart. See that file for the list.
35
+ - name: Verify
36
+ run: ./scripts/verify.sh
37
+ env:
38
+ UV_PYTHON: ${{ matrix.python }}
@@ -0,0 +1,97 @@
1
+ # Claiming an issue, done by the repository rather than by the claimant.
2
+ #
3
+ # AGENTS.md used to ask a claimant to comment and then move the issue to
4
+ # `lifecycle:doing`. Outside contributors cannot set labels, so step two was
5
+ # impossible for exactly the people the instruction was written for, and for
6
+ # three hours on 27 August #25 looked free after it had been claimed. Two
7
+ # contributors wrote the same fix.
8
+ #
9
+ # The first version of this workflow matched claim phrasings with a regex. On
10
+ # 28 August @raappo wrote "I'd love to" where the pattern expected "I'd like
11
+ # to", #64 stayed `lifecycle:ready` for thirteen hours with a PR already open
12
+ # against it, and a second contributor duplicated the work overnight. The
13
+ # symptom of a missed phrase is silence, which looks exactly like no claim.
14
+ #
15
+ # So there is no phrase list any more. **Any comment from a person on a
16
+ # `lifecycle:ready` issue claims it.** On a board this specific a comment is
17
+ # almost never anything else, and the asymmetry is decisive: a false positive
18
+ # costs one label and is undone by replying, while a false negative costs
19
+ # somebody a night of duplicated work. The confirmation comment says how to
20
+ # undo it, which is what makes the trade safe.
21
+
22
+ name: Claim
23
+
24
+ on:
25
+ issue_comment:
26
+ types: [created]
27
+
28
+ permissions:
29
+ issues: write
30
+
31
+ jobs:
32
+ claim:
33
+ if: ${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' }}
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/github-script@v7
37
+ with:
38
+ script: |
39
+ const owner = context.repo.owner;
40
+ const repo = context.repo.repo;
41
+ const issue_number = context.issue.number;
42
+ const claimant = context.payload.comment.user.login;
43
+
44
+ if (claimant === owner) return; // the maintainer is not claiming
45
+
46
+ const labels = context.payload.issue.labels.map((l) => l.name);
47
+ const ready = labels.includes("lifecycle:ready");
48
+ const doing = labels.includes("lifecycle:doing");
49
+ if (!ready && !doing) return; // spec, blocked or done: not claimable
50
+
51
+ const comments = await github.paginate(github.rest.issues.listComments, {
52
+ owner, repo, issue_number, per_page: 100,
53
+ });
54
+
55
+ // The first person who was not the maintainer or a bot. Under this
56
+ // rule that comment is the claim, whatever it said.
57
+ const first = comments.find(
58
+ (c) =>
59
+ c.id !== context.payload.comment.id &&
60
+ c.user.login !== owner &&
61
+ c.user.type !== "Bot",
62
+ );
63
+
64
+ if (doing && first && first.user.login !== claimant) {
65
+ await github.rest.issues.createComment({
66
+ owner, repo, issue_number,
67
+ body:
68
+ `@${claimant} before you spend time on this: ` +
69
+ `@${first.user.login} claimed it on ${first.created_at.slice(0, 10)} ` +
70
+ `(${first.html_url}).\n\n` +
71
+ "Please check with them before continuing, or take something else from " +
72
+ "[`lifecycle:ready`](../../issues?q=is%3Aopen+label%3Alifecycle%3Aready). " +
73
+ "If they have gone quiet, say so here and a maintainer will reassign it.",
74
+ });
75
+ return;
76
+ }
77
+
78
+ if (!ready) return; // already doing, and it is theirs
79
+
80
+ await github.rest.issues.addLabels({
81
+ owner, repo, issue_number, labels: ["lifecycle:doing"],
82
+ });
83
+ await github.rest.issues
84
+ .removeLabel({ owner, repo, issue_number, name: "lifecycle:ready" })
85
+ .catch(() => {});
86
+ await github.rest.issues.createComment({
87
+ owner, repo, issue_number,
88
+ body:
89
+ `Marked \`lifecycle:doing\` for @${claimant}.\n\n` +
90
+ "You cannot set labels on a repository you do not have write access to, " +
91
+ "so the repository does it for you. Branch as " +
92
+ `\`${issue_number}-short-slug\` and open a PR when ready.\n\n` +
93
+ "**If you were only asking a question and did not mean to claim this**, " +
94
+ "say so and a maintainer will move it back to `lifecycle:ready`. " +
95
+ "We would rather label one question by mistake than let two people " +
96
+ "write the same patch, which has happened twice here.",
97
+ });
@@ -0,0 +1,119 @@
1
+ # The side door.
2
+ #
3
+ # claim.yml guards the front door: someone comments on an issue, the repository
4
+ # moves the label and says who holds it. PR #59 walked straight past it by
5
+ # opening a pull request without ever commenting, which is a door claim.yml
6
+ # cannot see. By then the work was already written, which is the cost this is
7
+ # meant to avoid.
8
+ #
9
+ # Two checks, both disclosures rather than refusals. Neither fails the build:
10
+ # a maintainer decides, and a bot that blocks a good contribution is worse than
11
+ # one that says something a maintainer can ignore.
12
+ #
13
+ # SECURITY: pull_request_target runs with the base repository's token, which is
14
+ # what makes commenting on a fork PR possible at all. It is only safe because
15
+ # nothing here checks out or executes the pull request's code. Do not add
16
+ # actions/checkout to this file.
17
+
18
+ name: PR guard
19
+
20
+ on:
21
+ pull_request_target:
22
+ types: [opened, reopened]
23
+
24
+ permissions:
25
+ pull-requests: write
26
+ issues: read
27
+ contents: read
28
+
29
+ jobs:
30
+ guard:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/github-script@v7
34
+ with:
35
+ script: |
36
+ const owner = context.repo.owner;
37
+ const repo = context.repo.repo;
38
+ const pr = context.payload.pull_request;
39
+ const author = pr.user.login;
40
+ const number = pr.number;
41
+ const notes = [];
42
+
43
+ // ---- 1. is the issue this closes already held by somebody else?
44
+ const text = `${pr.title}\n${pr.body || ""}`;
45
+ const refs = new Set();
46
+ const KEYWORD =
47
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implements?|part of)\s+#(\d+)/gi;
48
+ for (const m of text.matchAll(KEYWORD)) refs.add(Number(m[1]));
49
+ // A bare #N in the title counts too: #59 said "(#25)" and nothing else.
50
+ for (const m of (pr.title || "").matchAll(/#(\d+)/g)) refs.add(Number(m[1]));
51
+
52
+ const CLAIM = new RegExp(
53
+ [
54
+ "starting\\s+(on|work)", "i'?m\\s+starting", "i\\s+am\\s+starting",
55
+ "i\\s+am\\s+ready\\s+to\\s+start", "claiming\\s+this", "taking\\s+this",
56
+ "working\\s+on\\s+this", "i'?d\\s+like\\s+to\\s+(work\\s+on|take)",
57
+ "i\\s+would\\s+like\\s+to\\s+work\\s+on",
58
+ ].join("|"),
59
+ "i",
60
+ );
61
+
62
+ for (const n of refs) {
63
+ if (n === number) continue;
64
+ let issue;
65
+ try {
66
+ issue = (await github.rest.issues.get({owner, repo, issue_number: n})).data;
67
+ } catch { continue; }
68
+ if (issue.pull_request) continue;
69
+
70
+ const labels = issue.labels.map((l) => (typeof l === "string" ? l : l.name));
71
+ if (!labels.includes("lifecycle:doing")) continue;
72
+
73
+ const comments = await github.paginate(github.rest.issues.listComments, {
74
+ owner, repo, issue_number: n, per_page: 100,
75
+ });
76
+ const prior = comments.filter(
77
+ (c) =>
78
+ c.user.login !== author &&
79
+ c.user.login !== owner &&
80
+ c.user.type !== "Bot" &&
81
+ CLAIM.test(c.body || ""),
82
+ );
83
+ if (!prior.length) continue;
84
+
85
+ const first = prior[0];
86
+ notes.push(
87
+ `**#${n} is already held.** @${first.user.login} claimed it on ` +
88
+ `${first.created_at.slice(0, 10)} (${first.html_url}), and it is labelled ` +
89
+ "`lifecycle:doing`.\n\n" +
90
+ "This is worth checking before you spend more time on it. Our claim " +
91
+ "automation only watches for a comment on the issue, so opening a pull " +
92
+ "request directly walks past it, which is our gap rather than yours.",
93
+ );
94
+ }
95
+
96
+ // ---- 2. does it change behaviour without changing a test?
97
+ const files = await github.paginate(github.rest.pulls.listFiles, {
98
+ owner, repo, pull_number: number, per_page: 100,
99
+ });
100
+ const paths = files.map((f) => f.filename);
101
+ const touchesSrc = paths.some((p) => p.startsWith("src/"));
102
+ const touchesTests = paths.some((p) => p.startsWith("tests/"));
103
+ if (touchesSrc && !touchesTests) {
104
+ notes.push(
105
+ "**This changes `src/` without changing `tests/`.**\n\n" +
106
+ "Most issues here list the tests they expect in their acceptance criteria, " +
107
+ "and `scripts/verify.sh` runs the suite as step 8. If the change is genuinely " +
108
+ "untestable, say so in the description and it will be taken at face value.",
109
+ );
110
+ }
111
+
112
+ if (!notes.length) return;
113
+ await github.rest.issues.createComment({
114
+ owner, repo, issue_number: number,
115
+ body:
116
+ notes.join("\n\n---\n\n") +
117
+ "\n\n<sub>Posted automatically by `pr-guard.yml`. Neither of these fails the " +
118
+ "build; a maintainer decides.</sub>",
119
+ });
@@ -0,0 +1,106 @@
1
+ # Publishing to PyPI, with no credential anywhere in this repository.
2
+ #
3
+ # Trusted Publishing (OIDC): PyPI is configured to trust this exact repository,
4
+ # workflow filename and environment, and GitHub mints a short-lived token per
5
+ # run. There is no API token to create, share, paste into a secret, rotate, or
6
+ # leak. If this file is renamed, or the environment below changes, PyPI stops
7
+ # trusting it and the upload fails closed.
8
+ #
9
+ # The build job runs the same verify.sh that guards every push, on both
10
+ # supported interpreters, before anything is uploaded. A release that skipped
11
+ # the checks would defeat the point of having them.
12
+
13
+ name: release
14
+
15
+ on:
16
+ push:
17
+ tags: ["v*"]
18
+ workflow_dispatch:
19
+ inputs:
20
+ target:
21
+ description: "Where to publish"
22
+ required: true
23
+ default: testpypi
24
+ type: choice
25
+ options: [testpypi, pypi]
26
+
27
+ jobs:
28
+ verify:
29
+ name: verify (python ${{ matrix.python }})
30
+ runs-on: ubuntu-latest
31
+ strategy:
32
+ fail-fast: false
33
+ matrix:
34
+ python: ["3.12", "3.13"]
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: astral-sh/setup-uv@v5
38
+ with:
39
+ enable-cache: true
40
+ - run: ./scripts/verify.sh
41
+ env:
42
+ UV_PYTHON: ${{ matrix.python }}
43
+
44
+ build:
45
+ needs: verify
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - uses: astral-sh/setup-uv@v5
50
+
51
+ # The tag is the source of truth for the version. A tag that disagrees
52
+ # with pyproject.toml means one of the two is a typo, and guessing which
53
+ # is how a wrong version reaches PyPI permanently: uploads cannot be
54
+ # replaced, only yanked.
55
+ - name: Tag matches pyproject version
56
+ if: startsWith(github.ref, 'refs/tags/v')
57
+ run: |
58
+ tag="${GITHUB_REF_NAME#v}"
59
+ pkg=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)
60
+ if [ "$tag" != "$pkg" ]; then
61
+ echo "tag v$tag does not match pyproject version $pkg" >&2
62
+ exit 1
63
+ fi
64
+ echo "v$tag matches pyproject"
65
+
66
+ - run: uv build
67
+
68
+ # Catches the metadata problems that make a page look broken on PyPI,
69
+ # before the upload rather than after it.
70
+ - name: Check the distribution metadata
71
+ run: uv run --isolated --no-project --with twine twine check --strict dist/*
72
+
73
+ - uses: actions/upload-artifact@v4
74
+ with:
75
+ name: dist
76
+ path: dist/
77
+
78
+ testpypi:
79
+ needs: build
80
+ if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
81
+ runs-on: ubuntu-latest
82
+ environment: testpypi
83
+ permissions:
84
+ id-token: write # the whole credential story: a short-lived OIDC token
85
+ steps:
86
+ - uses: actions/download-artifact@v4
87
+ with:
88
+ name: dist
89
+ path: dist/
90
+ - uses: pypa/gh-action-pypi-publish@release/v1
91
+ with:
92
+ repository-url: https://test.pypi.org/legacy/
93
+
94
+ pypi:
95
+ needs: build
96
+ if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
97
+ runs-on: ubuntu-latest
98
+ environment: pypi
99
+ permissions:
100
+ id-token: write
101
+ steps:
102
+ - uses: actions/download-artifact@v4
103
+ with:
104
+ name: dist
105
+ path: dist/
106
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .env
10
+ .DS_Store
11
+
12
+ # Dogfood artifacts — regenerate with `uv run python -m dogfood.build`.
13
+ dogfood/workspace/
14
+ dogfood/truth/
@@ -0,0 +1 @@
1
+ 3.12