pythinker-code 0.56.0__py3-none-any.whl → 0.58.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. pythinker_code/CHANGELOG.md +73 -0
  2. pythinker_code/agents/default/system.md +3 -3
  3. pythinker_code/agentspec.py +171 -11
  4. pythinker_code/benchmark/__init__.py +5 -0
  5. pythinker_code/benchmark/activity.py +88 -0
  6. pythinker_code/benchmark/bundled/__init__.py +1 -0
  7. pythinker_code/benchmark/bundled/suites/__init__.py +1 -0
  8. pythinker_code/benchmark/bundled/suites/pythinker-core.json +11 -0
  9. pythinker_code/benchmark/bundled/suites/pythinker-smoke.json +10 -0
  10. pythinker_code/benchmark/bundled/tasks/__init__.py +1 -0
  11. pythinker_code/benchmark/bundled/tasks/core-atomic-transfer.json +21 -0
  12. pythinker_code/benchmark/bundled/tasks/core-dedup-order.json +21 -0
  13. pythinker_code/benchmark/bundled/tasks/core-explicit-none-metadata.json +21 -0
  14. pythinker_code/benchmark/bundled/tasks/core-safe-path-join.json +21 -0
  15. pythinker_code/benchmark/bundled/tasks/smoke-add-small-function.json +21 -0
  16. pythinker_code/benchmark/bundled/tasks/smoke-edit-readme.json +20 -0
  17. pythinker_code/benchmark/bundled/tasks/smoke-fix-python-test.json +21 -0
  18. pythinker_code/benchmark/commands.py +633 -0
  19. pythinker_code/benchmark/compare.py +72 -0
  20. pythinker_code/benchmark/discovery.py +169 -0
  21. pythinker_code/benchmark/environment.py +74 -0
  22. pythinker_code/benchmark/errors.py +57 -0
  23. pythinker_code/benchmark/estimate.py +69 -0
  24. pythinker_code/benchmark/export.py +69 -0
  25. pythinker_code/benchmark/records.py +191 -0
  26. pythinker_code/benchmark/redact.py +46 -0
  27. pythinker_code/benchmark/report.py +143 -0
  28. pythinker_code/benchmark/runner.py +425 -0
  29. pythinker_code/benchmark/suites.py +82 -0
  30. pythinker_code/benchmark/swe.py +193 -0
  31. pythinker_code/benchmark/tasks.py +186 -0
  32. pythinker_code/benchmark/toolset_characterization.py +1118 -0
  33. pythinker_code/benchmark/types.py +11 -0
  34. pythinker_code/cli/system_prompt.py +2 -1
  35. pythinker_code/config.py +15 -0
  36. pythinker_code/skill/__init__.py +139 -8
  37. pythinker_code/skill/catalog.py +509 -0
  38. pythinker_code/soul/agent.py +168 -69
  39. pythinker_code/soul/btw.py +35 -10
  40. pythinker_code/soul/compaction_restore.py +3 -3
  41. pythinker_code/soul/context.py +871 -220
  42. pythinker_code/soul/dynamic_injection.py +120 -80
  43. pythinker_code/soul/dynamic_injections/active_skills.py +131 -0
  44. pythinker_code/soul/dynamic_injections/agent_list.py +2 -1
  45. pythinker_code/soul/dynamic_injections/model_defense.py +47 -12
  46. pythinker_code/soul/dynamic_injections/permissions_state.py +60 -9
  47. pythinker_code/soul/pythinkersoul.py +477 -188
  48. pythinker_code/soul/request_assembly.py +770 -0
  49. pythinker_code/soul/request_lifecycle.py +481 -0
  50. pythinker_code/soul/request_primitives.py +34 -0
  51. pythinker_code/soul/slash.py +139 -2
  52. pythinker_code/soul/toolset.py +14 -5
  53. pythinker_code/subagents/catalogue.py +398 -0
  54. pythinker_code/subagents/discovery.py +139 -22
  55. pythinker_code/subagents/runner.py +6 -2
  56. pythinker_code/telemetry/metrics.py +47 -0
  57. pythinker_code/tools/agent/__init__.py +19 -14
  58. pythinker_code/tools/skill/__init__.py +29 -10
  59. pythinker_code/ui/shell/__init__.py +26 -0
  60. pythinker_code/ui/shell/focus_model.py +105 -0
  61. pythinker_code/ui/shell/focus_surface.py +94 -0
  62. pythinker_code/ui/shell/prompt.py +204 -5
  63. pythinker_code/ui/shell/slash.py +3 -2
  64. pythinker_code/ui/shell/tui/__init__.py +17 -0
  65. pythinker_code/ui/shell/tui/components.py +94 -0
  66. pythinker_code/ui/shell/tui/diff.py +37 -0
  67. pythinker_code/ui/shell/tui/scene.py +20 -0
  68. pythinker_code/ui/shell/tui/scheduler.py +23 -0
  69. pythinker_code/ui/shell/tui/width.py +16 -0
  70. pythinker_code/ui/shell/visualize/__init__.py +8 -0
  71. pythinker_code/ui/shell/visualize/_activity_tree.py +2 -5
  72. pythinker_code/ui/shell/visualize/_blocks.py +136 -4
  73. pythinker_code/ui/shell/visualize/_interactive.py +83 -7
  74. pythinker_code/ui/shell/visualize/_live_view.py +96 -6
  75. pythinker_code/utils/frontmatter.py +14 -3
  76. pythinker_code/utils/pyinstaller.py +1 -0
  77. pythinker_code/utils/slashcmd.py +2 -1
  78. pythinker_code/web/static/assets/architecture-7EHR7CIX-S-vCW5iB.js +1 -0
  79. pythinker_code/web/static/assets/{architectureDiagram-3BPJPVTR-D-oTCQ9W.js → architectureDiagram-3BPJPVTR-DNjSUgP8.js} +1 -1
  80. pythinker_code/web/static/assets/{blockDiagram-GPEHLZMM-D34_j76S.js → blockDiagram-GPEHLZMM-Bj6WF362.js} +1 -1
  81. pythinker_code/web/static/assets/{bootstrap-CpUR7Koq.js → bootstrap-DPOVLodG.js} +5 -5
  82. pythinker_code/web/static/assets/{c4Diagram-AAUBKEIU-C2mqoh1h.js → c4Diagram-AAUBKEIU-GGQSrWG4.js} +1 -1
  83. pythinker_code/web/static/assets/channel-BF4DnhOY.js +1 -0
  84. pythinker_code/web/static/assets/{chunk-2J33WTMH-ShdbwtE-.js → chunk-2J33WTMH-OsiI6Shl.js} +1 -1
  85. pythinker_code/web/static/assets/{chunk-3OPIFGDE-CJUUK0QK.js → chunk-3OPIFGDE-BT9LH4XF.js} +1 -1
  86. pythinker_code/web/static/assets/{chunk-5ZQYHXKU-xeGaUwvY.js → chunk-5ZQYHXKU-BKI-R690.js} +1 -1
  87. pythinker_code/web/static/assets/{chunk-727SXJPM-Cy_RD8yP.js → chunk-727SXJPM-zD8K6bZU.js} +1 -1
  88. pythinker_code/web/static/assets/{chunk-AQP2D5EJ-DMNmGzA_.js → chunk-AQP2D5EJ-C8-RqfOY.js} +1 -1
  89. pythinker_code/web/static/assets/{chunk-CSCIHK7Q-BkY8yJxw.js → chunk-CSCIHK7Q-DauLS_KO.js} +1 -1
  90. pythinker_code/web/static/assets/{chunk-JAPRZBRM-R_RAdw32.js → chunk-JAPRZBRM-B3vK8rJr.js} +4 -4
  91. pythinker_code/web/static/assets/{chunk-KSCS5N6A-DOUIBKHM.js → chunk-KSCS5N6A-q_c8Otyz.js} +1 -1
  92. pythinker_code/web/static/assets/{chunk-L5ZTLDWV-D_5ebsa9.js → chunk-L5ZTLDWV-L38JietD.js} +1 -1
  93. pythinker_code/web/static/assets/{chunk-LZXEDZCA-CNIP5ZJc.js → chunk-LZXEDZCA-JUzQ08Fk.js} +2 -2
  94. pythinker_code/web/static/assets/{chunk-ND2GUHAM-CJ5TG2Ro.js → chunk-ND2GUHAM-B7lN__eE.js} +1 -1
  95. pythinker_code/web/static/assets/{chunk-NZK2D7GU-COffKE0S.js → chunk-NZK2D7GU-MT98-VRd.js} +1 -1
  96. pythinker_code/web/static/assets/{chunk-O5CBEL6O-C5w7EJCo.js → chunk-O5CBEL6O-HS6UvO8l.js} +1 -1
  97. pythinker_code/web/static/assets/{chunk-WU5MYG2G-DWLWBJUd.js → chunk-WU5MYG2G-2FStZlKC.js} +1 -1
  98. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-bQ8c0gns.js +1 -0
  99. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-bQ8c0gns.js +1 -0
  100. pythinker_code/web/static/assets/{code-block-IT6T5CEO-C0JwfXrb.js → code-block-IT6T5CEO-CSd98pzb.js} +1 -1
  101. pythinker_code/web/static/assets/{dagre-BM42HDAG-BS7OVne_.js → dagre-BM42HDAG-CRsWJlJl.js} +1 -1
  102. pythinker_code/web/static/assets/{diagram-2AECGRRQ-BTg3lFST.js → diagram-2AECGRRQ-x6OaKKPB.js} +1 -1
  103. pythinker_code/web/static/assets/{diagram-5GNKFQAL-BUAno9D9.js → diagram-5GNKFQAL-DC8c6FnZ.js} +1 -1
  104. pythinker_code/web/static/assets/{diagram-KO2AKTUF-CGpQcGTu.js → diagram-KO2AKTUF-B7qc5yfP.js} +1 -1
  105. pythinker_code/web/static/assets/{diagram-LMA3HP47-DdfoR8iH.js → diagram-LMA3HP47-BFPLlnJr.js} +1 -1
  106. pythinker_code/web/static/assets/{diagram-OG6HWLK6-B-2nIGQC.js → diagram-OG6HWLK6-B-WnMmPG.js} +1 -1
  107. pythinker_code/web/static/assets/{dist-v9L6Y4tR.js → dist-Co5o61Cn.js} +1 -1
  108. pythinker_code/web/static/assets/{erDiagram-TEJ5UH35-Dkn6nBXX.js → erDiagram-TEJ5UH35-Vt3XXS0U.js} +1 -1
  109. pythinker_code/web/static/assets/eventmodeling-FCH6USID-BleAriwz.js +1 -0
  110. pythinker_code/web/static/assets/{flowDiagram-I6XJVG4X-CqC1Lcvt.js → flowDiagram-I6XJVG4X-BIXHEjYH.js} +1 -1
  111. pythinker_code/web/static/assets/{ganttDiagram-6RSMTGT7-BAoDDdO1.js → ganttDiagram-6RSMTGT7--FC4MYE3.js} +1 -1
  112. pythinker_code/web/static/assets/{gitGraph-WXDBUCRP-B6BXhOMH.js → gitGraph-WXDBUCRP-IE4jYajm.js} +1 -1
  113. pythinker_code/web/static/assets/{gitGraphDiagram-PVQCEYII-DYk1hKu6.js → gitGraphDiagram-PVQCEYII-n6_B85cu.js} +1 -1
  114. pythinker_code/web/static/assets/{index-d4P4XfiK.js → index-Denir8Jc.js} +2 -2
  115. pythinker_code/web/static/assets/{info-J43DQDTF-EId6Oz5v.js → info-J43DQDTF-BL7RBP_1.js} +1 -1
  116. pythinker_code/web/static/assets/{infoDiagram-5YYISTIA-CVSEC85r.js → infoDiagram-5YYISTIA-DyvqlJzB.js} +1 -1
  117. pythinker_code/web/static/assets/{ishikawaDiagram-YF4QCWOH-B8YI7s2L.js → ishikawaDiagram-YF4QCWOH--AK0oQMB.js} +1 -1
  118. pythinker_code/web/static/assets/{journeyDiagram-JHISSGLW-BfAOteUW.js → journeyDiagram-JHISSGLW-DPt6uw-j.js} +1 -1
  119. pythinker_code/web/static/assets/{kanban-definition-UN3LZRKU-DC0NPat0.js → kanban-definition-UN3LZRKU-CMP28iqQ.js} +1 -1
  120. pythinker_code/web/static/assets/{line-RPziCRsV.js → line-QV0kz4Kh.js} +1 -1
  121. pythinker_code/web/static/assets/mermaid-VLURNSYL-Cw_TmpwX.js +1 -0
  122. pythinker_code/web/static/assets/{mermaid-parser.core-DJ-lswOc.js → mermaid-parser.core-5eX2XKzs.js} +2 -2
  123. pythinker_code/web/static/assets/{mermaid.core-BlZ1iz7p.js → mermaid.core-xSjkHKy4.js} +3 -3
  124. pythinker_code/web/static/assets/{mindmap-definition-RKZ34NQL-BmNncIUr.js → mindmap-definition-RKZ34NQL-SSQGdG4v.js} +1 -1
  125. pythinker_code/web/static/assets/{packet-YPE3B663-DNxSBx_x.js → packet-YPE3B663-DTfa3Ugh.js} +1 -1
  126. pythinker_code/web/static/assets/{pie-LRSECV5Y-4baAFGGq.js → pie-LRSECV5Y-D3jK8uBv.js} +1 -1
  127. pythinker_code/web/static/assets/{pieDiagram-4H26LBE5-D7VDURr8.js → pieDiagram-4H26LBE5--TRptUQU.js} +1 -1
  128. pythinker_code/web/static/assets/{quadrantDiagram-W4KKPZXB-5BNRSed4.js → quadrantDiagram-W4KKPZXB-Db9MtYHt.js} +1 -1
  129. pythinker_code/web/static/assets/{radar-GUYGQ44K-CJUK9U42.js → radar-GUYGQ44K-DFnRNCTk.js} +1 -1
  130. pythinker_code/web/static/assets/{requirementDiagram-4Y6WPE33-WrVH562b.js → requirementDiagram-4Y6WPE33-sTa-nhJU.js} +1 -1
  131. pythinker_code/web/static/assets/{sankeyDiagram-5OEKKPKP-bq7l110w.js → sankeyDiagram-5OEKKPKP-Bd2A0AYR.js} +1 -1
  132. pythinker_code/web/static/assets/{sequenceDiagram-3UESZ5HK-D7Co-TdH.js → sequenceDiagram-3UESZ5HK-gsJgyFMR.js} +1 -1
  133. pythinker_code/web/static/assets/{stateDiagram-AJRCARHV-BxWHnHG4.js → stateDiagram-AJRCARHV-D8aBQA8n.js} +1 -1
  134. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-CcaDggV3.js +1 -0
  135. pythinker_code/web/static/assets/{timeline-definition-PNZ67QCA-BPV0Bq-Q.js → timeline-definition-PNZ67QCA-DOTZZxx_.js} +1 -1
  136. pythinker_code/web/static/assets/{treeView-BLDUP644-D2A9BwMa.js → treeView-BLDUP644-DeRg38Vr.js} +1 -1
  137. pythinker_code/web/static/assets/{treemap-LRROVOQU-CKI0YtvA.js → treemap-LRROVOQU-DWxQ3QgK.js} +1 -1
  138. pythinker_code/web/static/assets/{vennDiagram-CIIHVFJN-BxR_Flz7.js → vennDiagram-CIIHVFJN-CYSR8VxH.js} +1 -1
  139. pythinker_code/web/static/assets/{wardley-L42UT6IY-DoJhfL99.js → wardley-L42UT6IY-DwndHzRK.js} +1 -1
  140. pythinker_code/web/static/assets/{wardleyDiagram-YWT4CUSO-C2cPq0nQ.js → wardleyDiagram-YWT4CUSO-By2G9X0c.js} +1 -1
  141. pythinker_code/web/static/assets/{xychartDiagram-2RQKCTM6-DbBm2yQI.js → xychartDiagram-2RQKCTM6-hEjj-Cb8.js} +1 -1
  142. pythinker_code/web/static/index.html +1 -1
  143. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/METADATA +47 -29
  144. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/RECORD +148 -104
  145. pythinker_code/web/static/assets/architecture-7EHR7CIX-BYw92_V4.js +0 -1
  146. pythinker_code/web/static/assets/channel-BGX8hmrU.js +0 -1
  147. pythinker_code/web/static/assets/classDiagram-4FO5ZUOK-Dw4bWLQm.js +0 -1
  148. pythinker_code/web/static/assets/classDiagram-v2-Q7XG4LA2-Dw4bWLQm.js +0 -1
  149. pythinker_code/web/static/assets/eventmodeling-FCH6USID-solrKU8m.js +0 -1
  150. pythinker_code/web/static/assets/mermaid-VLURNSYL-DEMWXrjn.js +0 -1
  151. pythinker_code/web/static/assets/stateDiagram-v2-BHNVJYJU-D6odN83X.js +0 -1
  152. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/WHEEL +0 -0
  153. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/entry_points.txt +0 -0
  154. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/licenses/LICENSE +0 -0
  155. {pythinker_code-0.56.0.dist-info → pythinker_code-0.58.0.dist-info}/licenses/NOTICE +0 -0
@@ -15,6 +15,79 @@ GitHub Releases page; `0.8.0` is the new starting line.
15
15
 
16
16
  ## Unreleased
17
17
 
18
+ ## 0.58.0 (2026-07-11)
19
+
20
+ - **Agent-spec loading is more defensive and truthful.** Subagent `path`, `extend`, and
21
+ `system_prompt_path` references that resolve outside their spec's directory (or the built-in
22
+ agents directory) are now rejected instead of loaded, and the markdown agent catalogue no longer
23
+ reclassifies an unexpected parser error as a harmless "invalid field" skip — only genuinely
24
+ malformed frontmatter is skipped.
25
+ - **Thinking and subagent activity now render cleanly in the terminal.** Live reasoning previews
26
+ render complete Markdown without exposing top-level HTML comments, activity-tree rows remain
27
+ visually stable, and the coral shimmer is reserved for the active verb spinner.
28
+ - **Agent request compatibility is now executable and reviewable.** Provider handoff,
29
+ prompt ordering, persisted-versus-effective history, context JSONL restoration,
30
+ agent projections, and Toolset lifecycle behavior now have explicit compatibility
31
+ contracts guarding future agent-core changes.
32
+ - **Skill discovery is bounded without making skills unreachable.** Pythinker now
33
+ searches one deterministic `SkillCatalog`, keeps exhaustive exact-name resolution,
34
+ and sends only task-relevant candidates to the model within an 8,000-character
35
+ request budget. The exhaustive `Runtime.skills` mapping remains available during
36
+ the compatibility window.
37
+ - **Agent requests now have one observable assembly path.** Required guidance fails
38
+ closed, optional guidance reports sanitized degradation outcomes, and the new
39
+ `/prompt-manifest` command explains the latest request composition without storing
40
+ raw prompts, user text, or provenance paths.
41
+ - **Conversation history updates are transactional.** Normal appends persist before
42
+ changing memory, while compaction, pruning, revert, and clear flows use atomic
43
+ replacement with coherent cancellation and rollback behavior. Concurrent revert
44
+ conflicts now stop after a bounded retry budget instead of starving indefinitely.
45
+ Existing JSONL records and restoration behavior remain compatible.
46
+ - **Agent definitions now resolve through one source-aware catalogue.** YAML and
47
+ Markdown definitions share deterministic precedence, collision diagnostics, and
48
+ safe provenance handling. Unknown fields warn in this release, become errors in
49
+ the following minor release, and the `LaborMarket`, `AgentTypeDefinition`, and
50
+ generated-wrapper adapters remain through that strict-default release.
51
+ - **Tool execution and MCP lifecycle behavior now have deterministic fault coverage.**
52
+ Publication rebuilds preserve the previous MCP tool registry if registration
53
+ fails. Characterization crossed the execution-overhead threshold, but a controlled
54
+ private extraction measured slightly worse and was reverted, so
55
+ `PythinkerToolset` remains the implementation boundary.
56
+ - **Agent-core seams hardened from review.** Persisted usage/checkpoint records reject
57
+ boolean and negative token counts, `update_token_count` validates at the boundary, a
58
+ temporary system-prompt descriptor is closed if `fdopen` fails, request finalization
59
+ surfaces every provider acknowledgement failure, a failed skill projection is always
60
+ recorded as failed (never blurred to not-applicable), and request-assembly telemetry no
61
+ longer emits unbounded per-request token values as metric attributes.
62
+
63
+ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.58.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest).
64
+
65
+ ## 0.57.0 (2026-07-05)
66
+
67
+ - **No more ghost/duplicate input prompt while the agent works.** After
68
+ submitting a prompt, the input card no longer fossilizes above the stream as
69
+ a second, ghostly prompt. The prompt bar and top border now stay visible
70
+ through turn-start and lazy-load frames, and running-agent output renders
71
+ through a stable diff-rendered scene (ported from Pi) so the input card no
72
+ longer jumps. An experimental Focus TUI mode (`tui.focus_mode`) pins the
73
+ composer and hides file activity behind a compact live shelf for a cleaner
74
+ fullscreen view, and active subagent tool work now surfaces in the pinned
75
+ status tail instead of a generic composing spinner.
76
+ - **Added a native `/benchmark` command suite.** Run deterministic local
77
+ Pythinker model evaluations with bundled smoke tasks and SWE-style JSONL
78
+ tasks (`/benchmark:swe`), plus `/benchmark compare`, `/benchmark export`,
79
+ and `/benchmark discover` actions that produce replayable artifacts,
80
+ activity metrics, and branded markdown reports.
81
+ - **Hardened benchmark and skill security edges.** SWE verification commands
82
+ are shape-validated before execution, benchmark discovery requires explicit
83
+ network opt-in, run IDs avoid clock collisions, runtime overrides restore
84
+ after setup failures, and active-skill deactivation failures now surface
85
+ instead of being swallowed. Explicitly invoked skills stay active across
86
+ later turns through a compact reminder until stopped by name or
87
+ "normal mode".
88
+
89
+ Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.57.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest).
90
+
18
91
  ## 0.56.0 (2026-07-02)
19
92
 
20
93
  - **Windows shell UI recovers from mid-session console blanking.** The TUI's
@@ -127,7 +127,7 @@ Default to `ImplementAndJudge` for non-trivial scoped edits (see §4.4); reserve
127
127
 
128
128
  **Background shell** (root agent only). Launch long-running commands via `Shell` with `run_in_background=true` and a short `description`; the system notifies you at terminal states. `TaskList` re-enumerates active tasks (especially after context compaction); `TaskOutput` gives non-blocking snapshots (`block=true` only to intentionally wait); `TaskStop` cancels. After starting a background task, default to returning control to the user. The only task-management slash command for users is `/task` — never invent subcommands like `/task list` or `/tasks`. Subagents and sessions without these tools must not assume background-task control.
129
129
 
130
- **Skills (`ReadSkill`).** Load a skill's exact instructions before applying its workflow — mandatory for `review-pr`, `diagnose-ci-failures`, `fix-errors`, `implement-specs`, `spec-driven-implementation`, `check-impl-against-spec`, `resolve-merge-conflicts`, and `create-pr`. Read skill details only when needed, to conserve context. Catalog and scope precedence in §12.
130
+ **Skills (`ReadSkill`).** Load a skill's exact instructions before applying its workflow — mandatory for `review-pr`, `diagnose-ci-failures`, `fix-errors`, `implement-specs`, `spec-driven-implementation`, `check-impl-against-spec`, `resolve-merge-conflicts`, and `create-pr`. If the user explicitly invokes or names a skill as an active mode, keep applying that loaded skill until they ask to stop it or return to normal mode. Read skill details only when needed, to conserve context. Catalog and scope precedence in §12.
131
131
 
132
132
  **Inline `/command` references.** Slash commands execute only as their own message starting with `/`. A `/command` or `/skill:<name>` mentioned mid-message did not auto-run, but it still expresses intent — act on it rather than leading your reply by reporting it as failed. For `/plan`, call `EnterPlanMode` (a real tool in your toolset, not just prose); for `/goal`, pursue the described objective until it is verifiably done; for `/skill:<name>`, load it via `ReadSkill` and apply it; for other guidance commands, apply the equivalent guidance yourself. Mention invoking the real command only when genuinely needed. Never silently drop such a reference.
133
133
 
@@ -255,8 +255,8 @@ Precedence per §2. `README`/`README.md` files are optional supplementary contex
255
255
 
256
256
  ## 12. Skills
257
257
 
258
- Skills are reusable, self-contained capability directories, each with a `SKILL.md` of instructions, examples, scripts, and reference material — specialized domain knowledge, workflow patterns, pre-configured tool chains, and templates. They are grouped by scope (`Project`, `User`, `Extra`, `Built-in`); when scopes define the same name, the more specific wins: **Project › User › Extra › Built-in.**
258
+ Skills are reusable, self-contained capability directories, each with a `SKILL.md` of instructions, examples, scripts, and reference material — specialized domain knowledge, workflow patterns, pre-configured tool chains, and templates. When scopes define the same name, the more specific wins: **Project › User › Extra › Built-in.**
259
259
 
260
260
  ${PYTHINKER_SKILLS}
261
261
 
262
- Identify the skills relevant to the current task and read their `SKILL.md` before applying the workflow (§5). If a skill `<name>` has a companion `<name>-local`, treat it as local project specialization applied after the core skill. Read skill details only when needed, to conserve the context window.
262
+ Identify the skills relevant to the current task and read their `SKILL.md` before applying the workflow (§5). If a skill `<name>` has a companion `<name>-local`, treat it as local project specialization applied after the core skill. Read skill details only when needed, to conserve the context window.
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
4
+ import re
3
5
  from dataclasses import dataclass
4
6
  from pathlib import Path
5
7
  from typing import Any, Literal, NamedTuple, cast
6
8
 
7
9
  import yaml
8
- from pydantic import BaseModel, Field
10
+ from pydantic import BaseModel, Field, ValidationError
9
11
 
10
12
  from pythinker_code.exception import AgentSpecError
11
13
 
@@ -89,6 +91,14 @@ class ResolvedAgentSpec:
89
91
  subagents: dict[str, SubagentSpec]
90
92
 
91
93
 
94
+ @dataclass(frozen=True, slots=True, kw_only=True)
95
+ class AgentSpecSourceValidation:
96
+ """Unknown fields observed in one canonical YAML source before projection."""
97
+
98
+ source_path: Path
99
+ field_paths: tuple[str, ...]
100
+
101
+
92
102
  def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
93
103
  """
94
104
  Load agent specification from file.
@@ -97,7 +107,22 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
97
107
  FileNotFoundError: If the agent spec file is not found.
98
108
  AgentSpecError: If the agent spec is not valid.
99
109
  """
100
- agent_spec = _load_agent_spec(agent_file)
110
+ agent_spec, _ = load_agent_spec_validated(agent_file)
111
+ return agent_spec
112
+
113
+
114
+ def load_agent_spec_validated(
115
+ agent_file: Path,
116
+ *,
117
+ forbid_unknown_fields: bool = False,
118
+ ) -> tuple[ResolvedAgentSpec, tuple[AgentSpecSourceValidation, ...]]:
119
+ """Load a spec and report raw unknown fields from every inherited source."""
120
+ validations: dict[Path, AgentSpecSourceValidation] = {}
121
+ agent_spec = _load_agent_spec(
122
+ agent_file,
123
+ _validations=validations,
124
+ _forbid_unknown_fields=forbid_unknown_fields,
125
+ )
101
126
  assert agent_spec.extend is None, "agent extension should be recursively resolved"
102
127
  if isinstance(agent_spec.name, Inherit):
103
128
  raise AgentSpecError("Agent name is required")
@@ -111,7 +136,7 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
111
136
  agent_spec.exclude_tools = []
112
137
  if isinstance(agent_spec.subagents, Inherit):
113
138
  agent_spec.subagents = {}
114
- return ResolvedAgentSpec(
139
+ resolved = ResolvedAgentSpec(
115
140
  name=agent_spec.name,
116
141
  system_prompt_path=agent_spec.system_prompt_path,
117
142
  system_prompt_args=agent_spec.system_prompt_args,
@@ -127,9 +152,36 @@ def load_agent_spec(agent_file: Path) -> ResolvedAgentSpec:
127
152
  exclude_tools=agent_spec.exclude_tools or [],
128
153
  subagents=agent_spec.subagents or {},
129
154
  )
155
+ return resolved, tuple(validations.values())
156
+
157
+
158
+ def _resolve_within_agent_roots(agent_file: Path, declared: str | Path) -> Path:
159
+ """Join *declared* onto *agent_file*'s directory, rejecting escaping paths.
160
+
161
+ Trusted local agent specs reference sibling files with relative paths. A
162
+ ``..`` traversal or symlink whose canonical target lands outside both the
163
+ originating spec's directory and the built-in agents directory is rejected
164
+ fail-closed as defense-in-depth, since the joined path is otherwise opened
165
+ or recursively loaded directly. The stored value keeps its ``.absolute()``
166
+ form, so permitted paths are unchanged.
167
+ """
168
+ parent = agent_file.parent
169
+ absolute = (parent / declared).absolute()
170
+ allowed_roots = (parent.resolve(), get_agents_dir().resolve())
171
+ if not any(absolute.resolve().is_relative_to(root) for root in allowed_roots):
172
+ raise AgentSpecError(
173
+ f"Agent spec reference {declared!r} resolves outside the permitted agent directories"
174
+ )
175
+ return absolute
130
176
 
131
177
 
132
- def _load_agent_spec(agent_file: Path, _visited: set[Path] | None = None) -> AgentSpec:
178
+ def _load_agent_spec(
179
+ agent_file: Path,
180
+ _visited: set[Path] | None = None,
181
+ *,
182
+ _validations: dict[Path, AgentSpecSourceValidation] | None = None,
183
+ _forbid_unknown_fields: bool = False,
184
+ ) -> AgentSpec:
133
185
  resolved = agent_file.resolve()
134
186
  if _visited is None:
135
187
  _visited = set()
@@ -149,24 +201,46 @@ def _load_agent_spec(agent_file: Path, _visited: set[Path] | None = None) -> Age
149
201
  raise AgentSpecError(f"Agent spec file must contain a mapping: {agent_file}")
150
202
  data = cast("dict[str, Any]", data)
151
203
 
204
+ unknown_fields, has_invalid_field_key = _unknown_agent_spec_fields(data)
205
+ if unknown_fields:
206
+ if has_invalid_field_key:
207
+ fields = ", ".join(unknown_fields)
208
+ raise AgentSpecError(f"Invalid agent field key: {fields}")
209
+ if _forbid_unknown_fields:
210
+ fields = ", ".join(unknown_fields)
211
+ raise AgentSpecError(f"Unknown fields in required agent source: {fields}")
212
+ if _validations is not None and resolved not in _validations:
213
+ _validations[resolved] = AgentSpecSourceValidation(
214
+ source_path=resolved,
215
+ field_paths=unknown_fields,
216
+ )
217
+
152
218
  version = str(data.get("version", DEFAULT_AGENT_SPEC_VERSION))
153
219
  if version not in SUPPORTED_AGENT_SPEC_VERSIONS:
154
220
  raise AgentSpecError(f"Unsupported agent spec version: {version}")
155
221
 
156
- agent_spec = AgentSpec(**data.get("agent", {}))
222
+ try:
223
+ agent_spec = AgentSpec(**data.get("agent", {}))
224
+ except (TypeError, ValidationError) as exc:
225
+ raise AgentSpecError("Agent spec contains an invalid known field") from exc
157
226
  if isinstance(agent_spec.system_prompt_path, Path):
158
- agent_spec.system_prompt_path = (
159
- agent_file.parent / agent_spec.system_prompt_path
160
- ).absolute()
227
+ agent_spec.system_prompt_path = _resolve_within_agent_roots(
228
+ agent_file, agent_spec.system_prompt_path
229
+ )
161
230
  if isinstance(agent_spec.subagents, dict):
162
231
  for v in agent_spec.subagents.values():
163
- v.path = (agent_file.parent / v.path).absolute()
232
+ v.path = _resolve_within_agent_roots(agent_file, v.path)
164
233
  if agent_spec.extend:
165
234
  if agent_spec.extend == "default":
166
235
  base_agent_file = DEFAULT_AGENT_FILE
167
236
  else:
168
- base_agent_file = (agent_file.parent / agent_spec.extend).absolute()
169
- base_agent_spec = _load_agent_spec(base_agent_file, _visited)
237
+ base_agent_file = _resolve_within_agent_roots(agent_file, agent_spec.extend)
238
+ base_agent_spec = _load_agent_spec(
239
+ base_agent_file,
240
+ _visited,
241
+ _validations=_validations,
242
+ _forbid_unknown_fields=_forbid_unknown_fields,
243
+ )
170
244
  if not isinstance(agent_spec.name, Inherit):
171
245
  base_agent_spec.name = agent_spec.name
172
246
  if not isinstance(agent_spec.system_prompt_path, Inherit):
@@ -207,3 +281,89 @@ def _load_agent_spec(agent_file: Path, _visited: set[Path] | None = None) -> Age
207
281
  base_agent_spec.subagents = agent_spec.subagents
208
282
  agent_spec = base_agent_spec
209
283
  return agent_spec
284
+
285
+
286
+ def _unknown_agent_spec_fields(data: dict[str, Any]) -> tuple[tuple[str, ...], bool]:
287
+ unknown: list[str] = []
288
+ has_invalid_key = False
289
+ for key in cast("dict[object, object]", data):
290
+ if isinstance(key, str) and key in {"version", "agent"}:
291
+ continue
292
+ rendered = render_agent_field_segment(key)
293
+ unknown.append(rendered.text)
294
+ has_invalid_key = has_invalid_key or rendered.structurally_invalid
295
+ raw_agent = data.get("agent")
296
+ if not isinstance(raw_agent, dict):
297
+ return tuple(sorted(unknown)), has_invalid_key
298
+ agent = cast("dict[str, Any]", raw_agent)
299
+ known_agent_fields = set(AgentSpec.model_fields)
300
+ for key in cast("dict[object, object]", agent):
301
+ if isinstance(key, str) and key in known_agent_fields:
302
+ continue
303
+ rendered = render_agent_field_segment(key)
304
+ unknown.append(f"agent.{rendered.text}")
305
+ has_invalid_key = has_invalid_key or rendered.structurally_invalid
306
+ raw_subagents = agent.get("subagents")
307
+ if isinstance(raw_subagents, dict):
308
+ known_subagent_fields = set(SubagentSpec.model_fields)
309
+ for name, raw_subagent in cast("dict[object, object]", raw_subagents).items():
310
+ rendered_name = render_agent_field_segment(name)
311
+ has_invalid_key = has_invalid_key or rendered_name.structurally_invalid
312
+ if rendered_name.structurally_invalid:
313
+ unknown.append(f"agent.subagents.{rendered_name.text}")
314
+ if not isinstance(raw_subagent, dict):
315
+ continue
316
+ for key in cast("dict[object, object]", raw_subagent):
317
+ if isinstance(key, str) and key in known_subagent_fields:
318
+ continue
319
+ rendered = render_agent_field_segment(key)
320
+ unknown.append(f"agent.subagents.{rendered_name.text}.{rendered.text}")
321
+ has_invalid_key = has_invalid_key or rendered.structurally_invalid
322
+ return tuple(sorted(unknown)), has_invalid_key
323
+
324
+
325
+ _FIELD_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
326
+ _SENSITIVE_FIELD_HINTS = (
327
+ "password",
328
+ "passwd",
329
+ "secret",
330
+ "token",
331
+ "api_key",
332
+ "apikey",
333
+ "access_key",
334
+ "private_key",
335
+ "credential",
336
+ "auth",
337
+ )
338
+
339
+
340
+ @dataclass(frozen=True, slots=True, kw_only=True)
341
+ class AgentFieldSegment:
342
+ text: str
343
+ redacted_for_safety: bool
344
+ structurally_invalid: bool
345
+
346
+
347
+ def render_agent_field_segment(value: object) -> AgentFieldSegment:
348
+ """Render a stable field segment without conflating redaction and validity."""
349
+ if isinstance(value, str):
350
+ lowered = value.casefold()
351
+ if _FIELD_IDENTIFIER_RE.fullmatch(value) and not any(
352
+ hint in lowered for hint in _SENSITIVE_FIELD_HINTS
353
+ ):
354
+ return AgentFieldSegment(
355
+ text=value,
356
+ redacted_for_safety=False,
357
+ structurally_invalid=False,
358
+ )
359
+ digest_input = f"str:{value}"
360
+ structurally_invalid = _FIELD_IDENTIFIER_RE.fullmatch(value) is None
361
+ else:
362
+ digest_input = f"{type(value).__qualname__}:{value!r}"
363
+ structurally_invalid = True
364
+ digest = hashlib.sha256(digest_input.encode(encoding="utf-8")).hexdigest()[:12]
365
+ return AgentFieldSegment(
366
+ text=f"field[{digest}]",
367
+ redacted_for_safety=True,
368
+ structurally_invalid=structurally_invalid,
369
+ )
@@ -0,0 +1,5 @@
1
+ """Native Pythinker Benchmark support."""
2
+
3
+ from pythinker_code.benchmark.commands import benchmark_usage
4
+
5
+ __all__ = ["benchmark_usage"]
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import json
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+
10
+ def summarize_benchmark_activity(
11
+ before: Mapping[str, str],
12
+ after: Mapping[str, str],
13
+ wire_file: Path,
14
+ wire_offset: int,
15
+ ) -> dict[str, object]:
16
+ added, removed = _changed_line_counts(before, after)
17
+ tool_calls_by_name = _tool_calls_by_name(wire_file, wire_offset)
18
+ shell_tool_calls = sum(
19
+ count for name, count in tool_calls_by_name.items() if name.casefold() in {"bash", "shell"}
20
+ )
21
+ return {
22
+ "changed_files_count": len(_changed_file_names(before, after)),
23
+ "added_lines": added,
24
+ "removed_lines": removed,
25
+ "tool_calls_by_name": dict(sorted(tool_calls_by_name.items())),
26
+ "shell_tool_calls": shell_tool_calls,
27
+ }
28
+
29
+
30
+ def _changed_file_names(before: Mapping[str, str], after: Mapping[str, str]) -> set[str]:
31
+ names = set(before) | set(after)
32
+ return {name for name in names if before.get(name) != after.get(name)}
33
+
34
+
35
+ def _changed_line_counts(before: Mapping[str, str], after: Mapping[str, str]) -> tuple[int, int]:
36
+ added = 0
37
+ removed = 0
38
+ for name in sorted(_changed_file_names(before, after)):
39
+ diff = difflib.ndiff(
40
+ before.get(name, "").splitlines(),
41
+ after.get(name, "").splitlines(),
42
+ )
43
+ for line in diff:
44
+ if line.startswith("+ "):
45
+ added += 1
46
+ elif line.startswith("- "):
47
+ removed += 1
48
+ return added, removed
49
+
50
+
51
+ def _tool_calls_by_name(wire_file: Path, offset: int) -> dict[str, int]:
52
+ counts: dict[str, int] = {}
53
+ if not wire_file.exists():
54
+ return counts
55
+ try:
56
+ with wire_file.open("r", encoding="utf-8", errors="replace") as f:
57
+ f.seek(offset)
58
+ for line in f:
59
+ try:
60
+ raw: object = json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if not isinstance(raw, dict):
64
+ continue
65
+ message = cast(dict[str, object], raw).get("message")
66
+ if not isinstance(message, dict):
67
+ continue
68
+ message_data = cast(dict[str, object], message)
69
+ if message_data.get("type") != "ToolCall":
70
+ continue
71
+ payload = message_data.get("payload")
72
+ if not isinstance(payload, dict):
73
+ continue
74
+ name = _tool_name(cast(dict[str, object], payload))
75
+ if isinstance(name, str) and name:
76
+ counts[name] = counts.get(name, 0) + 1
77
+ except OSError:
78
+ return {}
79
+ return counts
80
+
81
+
82
+ def _tool_name(payload: Mapping[str, object]) -> str | None:
83
+ function = payload.get("function")
84
+ if isinstance(function, dict):
85
+ name = cast(dict[str, object], function).get("name")
86
+ return name if isinstance(name, str) else None
87
+ legacy_name = payload.get("name")
88
+ return legacy_name if isinstance(legacy_name, str) else None
@@ -0,0 +1 @@
1
+ """Bundled benchmark definitions."""
@@ -0,0 +1 @@
1
+ """Bundled benchmark suites."""
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "pythinker-core",
3
+ "title": "Pythinker Core Suite",
4
+ "description": "Deterministic local coding tasks covering common agent benchmark failure modes.",
5
+ "tasks": [
6
+ "core-atomic-transfer",
7
+ "core-dedup-order",
8
+ "core-explicit-none-metadata",
9
+ "core-safe-path-join"
10
+ ]
11
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "pythinker-smoke",
3
+ "title": "Pythinker Smoke Suite",
4
+ "description": "Small deterministic tasks for validating the native benchmark runner.",
5
+ "tasks": [
6
+ "smoke-edit-readme",
7
+ "smoke-fix-python-test",
8
+ "smoke-add-small-function"
9
+ ]
10
+ }
@@ -0,0 +1 @@
1
+ """Bundled benchmark tasks."""
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "core-atomic-transfer",
3
+ "title": "Atomic Ledger Transfer",
4
+ "description": "Fix a money-transfer bug without leaving partial state behind.",
5
+ "prompt": "Fix ledger.py. Ledger.transfer(src, dst, amount) must move funds atomically: reject missing source accounts, missing destination accounts, non-positive amounts, and insufficient funds without changing any balance. Keep the public API minimal.",
6
+ "workspace": {
7
+ "files": {
8
+ "ledger.py": "class Ledger:\n def __init__(self, balances):\n self.balances = dict(balances)\n\n def transfer(self, src, dst, amount):\n self.balances[src] -= amount\n if self.balances[src] < 0:\n raise ValueError('insufficient funds')\n self.balances[dst] += amount\n",
9
+ "test_ledger.py": "import pytest\n\nfrom ledger import Ledger\n\n\ndef test_transfer_moves_funds():\n ledger = Ledger({'a': 10, 'b': 1})\n ledger.transfer('a', 'b', 4)\n assert ledger.balances == {'a': 6, 'b': 5}\n\n\ndef test_insufficient_funds_rolls_back():\n ledger = Ledger({'a': 3, 'b': 1})\n with pytest.raises(ValueError):\n ledger.transfer('a', 'b', 5)\n assert ledger.balances == {'a': 3, 'b': 1}\n\n\ndef test_missing_destination_does_not_create_accounts():\n ledger = Ledger({'a': 3})\n with pytest.raises((KeyError, ValueError)):\n ledger.transfer('a', 'missing', 1)\n assert ledger.balances == {'a': 3}\n\n\ndef test_missing_source_rolls_back():\n ledger = Ledger({'a': 3, 'b': 1})\n with pytest.raises((KeyError, ValueError)):\n ledger.transfer('missing', 'b', 1)\n assert ledger.balances == {'a': 3, 'b': 1}\n\n\n@pytest.mark.parametrize('amount', [0, -1])\ndef test_non_positive_amount_rejected(amount):\n ledger = Ledger({'a': 3, 'b': 1})\n with pytest.raises(ValueError):\n ledger.transfer('a', 'b', amount)\n assert ledger.balances == {'a': 3, 'b': 1}\n\n\ndef test_non_positive_amount_checked_before_accounts():\n ledger = Ledger({'a': 3, 'b': 1})\n with pytest.raises(ValueError):\n ledger.transfer('missing', 'b', -1)\n assert ledger.balances == {'a': 3, 'b': 1}\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_ledger.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 180,
18
+ "max_steps": 60
19
+ },
20
+ "tags": ["core", "python", "state", "deterministic"]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "core-dedup-order",
3
+ "title": "Stable Deduplication",
4
+ "description": "Preserve first-seen order while removing duplicates.",
5
+ "prompt": "Fix seqkit.py. unique(items) must return unique values from any iterable in first-seen order. It must support unhashable values such as dictionaries and must not mutate the input.",
6
+ "workspace": {
7
+ "files": {
8
+ "seqkit.py": "def unique(items):\n return sorted(set(items))\n",
9
+ "test_seqkit.py": "from seqkit import unique\n\n\ndef test_unique_preserves_first_seen_order():\n assert unique(['b', 'a', 'b', 'c', 'a']) == ['b', 'a', 'c']\n\n\ndef test_unique_supports_unhashable_dicts():\n first = {'id': 1, 'name': 'first'}\n duplicate = {'id': 1, 'name': 'first'}\n second = {'id': 2, 'name': 'second'}\n assert unique([first, duplicate, second, first]) == [first, second]\n\n\ndef test_unique_does_not_mutate_input():\n values = ['b', 'a', 'b']\n assert unique(values) == ['b', 'a']\n assert values == ['b', 'a', 'b']\n\n\ndef test_unique_accepts_generators():\n assert unique(x for x in ['a', 'a', 'b']) == ['a', 'b']\n\n\ndef test_unique_keeps_falsey_values_distinct():\n assert unique([0, False, '', None, 0, None]) == [0, '', None]\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_seqkit.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 180,
18
+ "max_steps": 60
19
+ },
20
+ "tags": ["core", "python", "algorithm", "deterministic"]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "core-explicit-none-metadata",
3
+ "title": "Explicit None Metadata",
4
+ "description": "Distinguish omitted metadata from explicit None.",
5
+ "prompt": "Fix metadata.py. build_driver_info must distinguish omitted name/version from explicitly passing None: omitted fields use defaults, explicit None suppresses that field, and if both are explicitly None the result is empty. Other explicit falsey values, such as empty string, False, or 0, must be preserved.",
6
+ "workspace": {
7
+ "files": {
8
+ "metadata.py": "DEFAULT_NAME = 'pythinker-client'\nDEFAULT_VERSION = '1.0'\n\n\ndef build_driver_info(name=None, version=None):\n return {\n 'name': name or DEFAULT_NAME,\n 'version': version or DEFAULT_VERSION,\n }\n",
9
+ "test_metadata.py": "from metadata import build_driver_info\n\n\ndef test_omitted_fields_use_defaults():\n assert build_driver_info() == {'name': 'pythinker-client', 'version': '1.0'}\n\n\ndef test_explicit_none_suppresses_one_field():\n assert build_driver_info(name=None) == {'version': '1.0'}\n assert build_driver_info(version=None) == {'name': 'pythinker-client'}\n\n\ndef test_explicit_none_for_both_fields_returns_empty_metadata():\n assert build_driver_info(name=None, version=None) == {}\n\n\ndef test_explicit_values_are_used():\n assert build_driver_info(name='wrapper', version='2.5') == {'name': 'wrapper', 'version': '2.5'}\n\n\ndef test_falsey_explicit_values_are_preserved():\n assert build_driver_info(name='', version='0') == {'name': '', 'version': '0'}\n assert build_driver_info(name=False, version=0) == {'name': False, 'version': 0}\n assert build_driver_info(name=None, version='') == {'version': ''}\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_metadata.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 180,
18
+ "max_steps": 60
19
+ },
20
+ "tags": ["core", "python", "api-semantics", "deterministic"]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "core-safe-path-join",
3
+ "title": "Safe Path Join",
4
+ "description": "Reject path traversal while keeping normal relative paths usable.",
5
+ "prompt": "Fix paths.py. safe_join(root, user_path) must return a Path inside root for normal relative paths, and reject absolute paths, sibling-prefix paths, parent traversal, or symlink traversal outside root with ValueError.",
6
+ "workspace": {
7
+ "files": {
8
+ "paths.py": "from pathlib import Path\n\n\ndef safe_join(root, user_path):\n return Path(root) / user_path\n",
9
+ "test_paths.py": "from pathlib import Path\n\nimport pytest\n\nfrom paths import safe_join\n\n\ndef test_safe_join_allows_nested_relative_path(tmp_path):\n root = tmp_path / 'root'\n root.mkdir()\n assert safe_join(root, 'logs/app.txt') == root.resolve() / 'logs/app.txt'\n\n\ndef test_safe_join_allows_root_relative_path(tmp_path):\n root = tmp_path / 'root'\n root.mkdir()\n assert safe_join(root, '.') == root.resolve()\n\n\ndef test_safe_join_rejects_parent_traversal(tmp_path):\n root = tmp_path / 'root'\n root.mkdir()\n with pytest.raises(ValueError):\n safe_join(root, '../secret.txt')\n\n\ndef test_safe_join_rejects_absolute_path(tmp_path):\n root = tmp_path / 'root'\n root.mkdir()\n with pytest.raises(ValueError):\n safe_join(root, Path('/tmp/secret.txt'))\n\n\ndef test_safe_join_rejects_sibling_prefix_absolute_path(tmp_path):\n root = tmp_path / 'root'\n sibling = tmp_path / 'root-sibling'\n root.mkdir()\n sibling.mkdir()\n with pytest.raises(ValueError):\n safe_join(root, sibling / 'secret.txt')\n\n\ndef test_safe_join_rejects_symlink_escape(tmp_path):\n root = tmp_path / 'root'\n outside = tmp_path / 'outside'\n root.mkdir()\n outside.mkdir()\n link = root / 'link'\n try:\n link.symlink_to(outside, target_is_directory=True)\n except OSError:\n pytest.skip('symlinks unavailable')\n with pytest.raises(ValueError):\n safe_join(root, 'link/secret.txt')\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_paths.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 180,
18
+ "max_steps": 60
19
+ },
20
+ "tags": ["core", "python", "security", "deterministic"]
21
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "smoke-add-small-function",
3
+ "title": "Add Small Function",
4
+ "description": "Add a tiny function required by a deterministic check.",
5
+ "prompt": "Add a slugify(text) function to strings.py. It should lowercase text, strip leading/trailing whitespace, and replace spaces with hyphens.",
6
+ "workspace": {
7
+ "files": {
8
+ "strings.py": "",
9
+ "test_strings.py": "from strings import slugify\n\n\ndef test_slugify():\n assert slugify(' Hello Benchmark ') == 'hello-benchmark'\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_strings.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 120,
18
+ "max_steps": 30
19
+ },
20
+ "tags": ["smoke", "offline", "deterministic"]
21
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "smoke-edit-readme",
3
+ "title": "Edit README",
4
+ "description": "Update a README with a requested sentence.",
5
+ "prompt": "Update README.md to include a short sentence explaining that this project is used for a Pythinker benchmark smoke test.",
6
+ "workspace": {
7
+ "files": {
8
+ "README.md": "# Example Project\n\n"
9
+ }
10
+ },
11
+ "verification": {
12
+ "type": "command",
13
+ "command": "python - <<'PY'\nfrom pathlib import Path\ntext = Path('README.md').read_text(encoding='utf-8')\nassert 'Pythinker benchmark smoke test' in text\nPY"
14
+ },
15
+ "limits": {
16
+ "timeout_seconds": 120,
17
+ "max_steps": 30
18
+ },
19
+ "tags": ["smoke", "offline", "deterministic"]
20
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "id": "smoke-fix-python-test",
3
+ "title": "Fix Python Test",
4
+ "description": "Fix a small Python function so the included test passes.",
5
+ "prompt": "Fix calc.py so python -m pytest test_calc.py passes. Keep the change minimal.",
6
+ "workspace": {
7
+ "files": {
8
+ "calc.py": "def add(a, b):\n return a - b\n",
9
+ "test_calc.py": "from calc import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n"
10
+ }
11
+ },
12
+ "verification": {
13
+ "type": "command",
14
+ "command": "python -m pytest test_calc.py -q"
15
+ },
16
+ "limits": {
17
+ "timeout_seconds": 120,
18
+ "max_steps": 30
19
+ },
20
+ "tags": ["smoke", "offline", "deterministic"]
21
+ }