@deftai/directive-content 0.55.1 → 0.56.0

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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env python3
2
+ """ip_risk.py -- Permissive heuristic for detecting third-party intellectual
3
+ property (IP) references in interview / research text (#738).
4
+
5
+ The detector is intentionally permissive: it errs on the side of false
6
+ positives so that downstream interview steps can ask the user the
7
+ monetization-intent question rather than silently letting an IP-adjacent
8
+ project sail through PRD / SPECIFICATION generation without legal-risk
9
+ flagging.
10
+
11
+ Usage::
12
+
13
+ from ip_risk import detect_ip_terms, ip_risk_scope_items, plain_risk_summary
14
+
15
+ hits = detect_ip_terms("A Magic: The Gathering deck-builder app")
16
+ if hits:
17
+ items = ip_risk_scope_items(monetization_intent="commercial")
18
+ summary = plain_risk_summary(hits, monetization_intent="commercial")
19
+
20
+ The full guidance (heuristic categories, question script, minimum-protection
21
+ checklist) lives in ``references/ip-risk.md``. Keep the term lists in this
22
+ module synchronised with that document.
23
+
24
+ Exit codes (CLI mode):
25
+
26
+ 0 -- no IP terms detected
27
+ 1 -- IP terms detected (one term per line on stdout)
28
+ 2 -- usage error (no input provided)
29
+
30
+ This module is *advisory*. It does NOT provide legal advice and MUST NOT be
31
+ used as a substitute for lawyer consultation when the project is commercial
32
+ and IP-adjacent.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import sys
39
+ from collections.abc import Iterable
40
+ from dataclasses import dataclass
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Heuristic term lists (#738)
44
+ # ---------------------------------------------------------------------------
45
+ # Curated permissive set keyed by category. All matching is case-insensitive
46
+ # and word-boundary based -- substring matches inside a longer word
47
+ # ("magicwand", "starcraft") do NOT trigger.
48
+ #
49
+ # The lists are intentionally short and well-known. The heuristic is meant to
50
+ # catch the *most common* IP-adjacent project ideas that come up in interview
51
+ # sessions (the #151 playtester case was a Magic: The Gathering deck builder),
52
+ # not to be a comprehensive trademark database.
53
+
54
+ _BRANDED_GAMES_AND_UNIVERSES: tuple[str, ...] = (
55
+ # Tabletop / collectable card games
56
+ "Magic: The Gathering",
57
+ "Magic the Gathering",
58
+ "MTG",
59
+ "Yu-Gi-Oh",
60
+ "Yugioh",
61
+ "Pokemon",
62
+ "Pokémon",
63
+ "Dungeons and Dragons",
64
+ "Dungeons & Dragons",
65
+ "D&D",
66
+ "Warhammer",
67
+ # Video game franchises
68
+ "Mario",
69
+ "Zelda",
70
+ "Final Fantasy",
71
+ "Halo",
72
+ "Call of Duty",
73
+ "Fortnite",
74
+ "Minecraft",
75
+ "Roblox",
76
+ "League of Legends",
77
+ "World of Warcraft",
78
+ "WoW",
79
+ "Overwatch",
80
+ "Counter-Strike",
81
+ "Valorant",
82
+ "Apex Legends",
83
+ # Fictional universes
84
+ "Star Wars",
85
+ "Star Trek",
86
+ "Marvel",
87
+ "DC Comics",
88
+ "Harry Potter",
89
+ "Lord of the Rings",
90
+ "Middle-earth",
91
+ "Game of Thrones",
92
+ "Westeros",
93
+ "Disney",
94
+ "Pixar",
95
+ )
96
+
97
+ _BRANDED_CHARACTERS: tuple[str, ...] = (
98
+ "Mickey Mouse",
99
+ "Spider-Man",
100
+ "Spiderman",
101
+ "Batman",
102
+ "Superman",
103
+ "Wonder Woman",
104
+ "Iron Man",
105
+ "Hulk",
106
+ "Captain America",
107
+ "Pikachu",
108
+ "Sonic the Hedgehog",
109
+ "Luigi",
110
+ "Princess Peach",
111
+ "Kirby",
112
+ "Master Chief",
113
+ "Lara Croft",
114
+ "Indiana Jones",
115
+ "James Bond",
116
+ )
117
+
118
+ _SPORTS_LEAGUES: tuple[str, ...] = (
119
+ "NFL",
120
+ "NBA",
121
+ "MLB",
122
+ "NHL",
123
+ "MLS",
124
+ "FIFA",
125
+ "UEFA",
126
+ "Premier League",
127
+ "La Liga",
128
+ "Bundesliga",
129
+ "Olympics",
130
+ "Olympic Games",
131
+ "Super Bowl",
132
+ "World Cup",
133
+ )
134
+
135
+ _BRANDED_PRODUCTS: tuple[str, ...] = (
136
+ "iPhone",
137
+ "iPad",
138
+ "MacBook",
139
+ "AirPods",
140
+ "PlayStation",
141
+ "Xbox",
142
+ "Nintendo Switch",
143
+ "Coca-Cola",
144
+ "Pepsi",
145
+ "Starbucks",
146
+ "McDonald's",
147
+ "Lego",
148
+ "Barbie",
149
+ "Hot Wheels",
150
+ )
151
+
152
+ _MUSIC_AND_FILM: tuple[str, ...] = (
153
+ "Taylor Swift",
154
+ "Beyonce",
155
+ "Beyoncé",
156
+ # NOTE: "BTS" and "Drake" were removed (Greptile P2 #775) -- both
157
+ # false-positive heavily on common technical / proper-noun uses
158
+ # (BTS = Build-Test-Ship / Behind-The-Scenes / bug-tracking;
159
+ # Drake = the duck, the surname, Drake University, Sir Francis Drake,
160
+ # the Drake equation). Re-add only with a music-specific surrounding
161
+ # context check.
162
+ "Spotify",
163
+ "Netflix",
164
+ "Hulu",
165
+ "HBO",
166
+ )
167
+
168
+ # Generic fictional-universe terms that often signal IP-adjacency even
169
+ # without a specific brand name. Conservative list -- single common nouns
170
+ # like "wizard" are NOT included to avoid pathological false positives.
171
+ _FICTIONAL_UNIVERSE_TERMS: tuple[str, ...] = (
172
+ "Hogwarts",
173
+ "Jedi",
174
+ "Sith",
175
+ "Death Star",
176
+ "Hobbit",
177
+ "Vulcan",
178
+ "Klingon",
179
+ "Mandalorian",
180
+ "Force-sensitive",
181
+ "Muggle",
182
+ "Quidditch",
183
+ "Tatooine",
184
+ )
185
+
186
+ _CATEGORIES: dict[str, tuple[str, ...]] = {
187
+ "branded-game-or-universe": _BRANDED_GAMES_AND_UNIVERSES,
188
+ "branded-character": _BRANDED_CHARACTERS,
189
+ "sports-league": _SPORTS_LEAGUES,
190
+ "branded-product": _BRANDED_PRODUCTS,
191
+ "music-or-film": _MUSIC_AND_FILM,
192
+ "fictional-universe-term": _FICTIONAL_UNIVERSE_TERMS,
193
+ }
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class IPHit:
198
+ """A single IP-term detection hit."""
199
+
200
+ term: str
201
+ category: str
202
+
203
+ def __str__(self) -> str: # pragma: no cover - trivial
204
+ return f"{self.term} ({self.category})"
205
+
206
+
207
+ # Pre-compile a regex per category for fast scanning. Each term is escaped
208
+ # and wrapped in word boundaries so substring matches inside a longer word
209
+ # do not trigger ("magicwand" should NOT match "Magic").
210
+ def _compile_category(terms: Iterable[str]) -> re.Pattern[str]:
211
+ # Use a non-word-char lookaround that also tolerates leading/trailing
212
+ # punctuation common in titles (e.g. "Magic: The Gathering" appearing
213
+ # mid-sentence). Word boundaries (\b) work for ASCII; we accept the
214
+ # ASCII-only behavior for the heuristic.
215
+ escaped = [re.escape(term) for term in terms]
216
+ pattern = r"(?i)(?<!\w)(?:" + "|".join(escaped) + r")(?!\w)"
217
+ return re.compile(pattern)
218
+
219
+
220
+ _CATEGORY_PATTERNS: dict[str, re.Pattern[str]] = {
221
+ category: _compile_category(terms) for category, terms in _CATEGORIES.items()
222
+ }
223
+
224
+
225
+ def detect_ip_terms(text: str) -> list[IPHit]:
226
+ """Scan *text* for known IP terms; return a deduplicated list of hits.
227
+
228
+ The scan is permissive (case-insensitive, word-boundary based) and
229
+ intentionally err on the side of false positives. An empty list means
230
+ "no known IP terms detected" -- it does NOT mean "this project is
231
+ free of IP risk", since the heuristic only knows about the curated
232
+ term lists above.
233
+
234
+ Hits are deduplicated by ``(term, category)`` pair preserving first
235
+ appearance order. The original surface form from *text* is preserved
236
+ (e.g. ``"magic the gathering"`` keeps its lowercase form even though
237
+ the canonical term is ``"Magic the Gathering"``).
238
+ """
239
+ if not text:
240
+ return []
241
+ seen: set[tuple[str, str]] = set()
242
+ hits: list[IPHit] = []
243
+ for category, pattern in _CATEGORY_PATTERNS.items():
244
+ for match in pattern.finditer(text):
245
+ term = match.group(0)
246
+ key = (term.lower(), category)
247
+ if key in seen:
248
+ continue
249
+ seen.add(key)
250
+ hits.append(IPHit(term=term, category=category))
251
+ return hits
252
+
253
+
254
+ def is_ip_adjacent(text: str) -> bool:
255
+ """Return True iff *text* contains at least one detected IP term."""
256
+ return bool(detect_ip_terms(text))
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Monetization-intent branching + scope-item generation (#738)
261
+ # ---------------------------------------------------------------------------
262
+
263
+ _VALID_INTENTS: frozenset[str] = frozenset({"personal", "commercial", "unknown"})
264
+
265
+
266
+ def _validate_intent(intent: str) -> str:
267
+ """Normalise and validate a monetization-intent value.
268
+
269
+ Accepts ``"personal"``, ``"commercial"``, or ``"unknown"`` (case-
270
+ insensitive). Anything else raises ``ValueError`` -- the interview
271
+ flow MUST capture an explicit answer when IP is detected, so an
272
+ unrecognised intent here indicates a programming error in the caller.
273
+ """
274
+ if not isinstance(intent, str):
275
+ raise ValueError(f"monetization_intent must be a string, got {type(intent)!r}")
276
+ normalized = intent.strip().lower()
277
+ if normalized not in _VALID_INTENTS:
278
+ raise ValueError(
279
+ f"monetization_intent {intent!r} not in {sorted(_VALID_INTENTS)}"
280
+ )
281
+ return normalized
282
+
283
+
284
+ def ip_risk_scope_items(monetization_intent: str) -> list[dict[str, str]]:
285
+ """Return the canonical IP-risk protection scope items for SPECIFICATION
286
+ injection.
287
+
288
+ The minimum-protection checklist (see ``references/ip-risk.md``) lands
289
+ three scope items in the spec:
290
+
291
+ - **Disclaimer stub** -- a "not affiliated with / not endorsed by"
292
+ front-of-app notice the implementation phase fills in once the
293
+ specific IP holder is named.
294
+ - **API-only-asset-access policy** -- never bundle assets (images,
295
+ audio, video, text) from the third-party IP; access only via
296
+ official APIs that grant a license to use them, and gate that
297
+ access behind the user's own credentials.
298
+ - **Hosting policy** -- self-hosted private use only, OR commercial
299
+ hosting only after legal review confirms the licensing terms allow
300
+ it.
301
+
302
+ All three items are emitted regardless of monetization intent because
303
+ even personal IP-adjacent projects can leak into commercial use over
304
+ time. The ``Acceptance`` narrative on each item is tightened to the
305
+ commercial-level checklist (lawyer-confirmed terms, written license,
306
+ etc.) for **any intent other than ``"personal"``** -- the
307
+ wrong-side-of-safe policy means that ``"unknown"`` (interview hasn't
308
+ captured an explicit answer yet) inherits the stricter commercial
309
+ checklist. Only the explicit ``"personal"`` answer relaxes the
310
+ acceptance language.
311
+
312
+ The returned items are plain dicts compatible with
313
+ ``vBRIEF v0.6 PlanItem`` shape (``title``, ``status``, ``narrative``).
314
+ Callers append them to ``plan.items`` on the
315
+ ``specification.vbrief.json`` draft so they flow naturally into the
316
+ rendered SPECIFICATION.md via the existing ``scripts/spec_render.py``
317
+ pipeline -- no spec_render.py modification is required.
318
+ """
319
+ intent = _validate_intent(monetization_intent)
320
+ # Wrong-side-of-safe policy (Greptile P1 #775): treat anything other than
321
+ # the explicit `personal` answer as commercial-level. `unknown` (the
322
+ # interview is still asking the question) MUST inherit the stricter
323
+ # checklist so the spec carries lawyer-confirmed acceptance criteria
324
+ # by default. The interview MUST still resolve `unknown` -> `personal` /
325
+ # `commercial` before the confirmation gate; this is just the safe
326
+ # fallback for the scope-item shape if the call lands first.
327
+ commercial = intent != "personal"
328
+
329
+ base_acceptance = (
330
+ "Lawyer-confirmed wording before public release"
331
+ if commercial
332
+ else "Reviewed by the project owner before any public release"
333
+ )
334
+ asset_acceptance = (
335
+ "All third-party assets reach the app via official APIs only with a "
336
+ "license that explicitly permits the planned use; no assets bundled "
337
+ "in the repository or build artifacts; lawyer-confirmed before public "
338
+ "release"
339
+ if commercial
340
+ else "All third-party assets reach the app via official APIs only; "
341
+ "no assets bundled in the repository or build artifacts"
342
+ )
343
+ hosting_acceptance = (
344
+ "Hosting plan reviewed by counsel; written license terms cover the "
345
+ "deployment region and audience; revenue model documented"
346
+ if commercial
347
+ else "Self-hosted private use only; do not deploy publicly until a "
348
+ "monetization decision is made and re-reviewed against this rule"
349
+ )
350
+
351
+ return [
352
+ {
353
+ "title": "IP-risk: disclaimer stub on the app's front surface",
354
+ "status": "pending",
355
+ "narrative": {
356
+ "Description": (
357
+ "Add a 'not affiliated with / not endorsed by' notice on "
358
+ "the app's first user-visible surface (splash screen, "
359
+ "landing page, or CLI banner)."
360
+ ),
361
+ "Acceptance": base_acceptance,
362
+ "Traces": "IP-1",
363
+ },
364
+ },
365
+ {
366
+ "title": "IP-risk: API-only third-party asset access policy",
367
+ "status": "pending",
368
+ "narrative": {
369
+ "Description": (
370
+ "Never bundle third-party IP assets (images, audio, "
371
+ "video, text, card data, character likenesses) in the "
372
+ "repository or build artifacts. Access only via official "
373
+ "APIs that grant a license."
374
+ ),
375
+ "Acceptance": asset_acceptance,
376
+ "Traces": "IP-2",
377
+ },
378
+ },
379
+ {
380
+ "title": "IP-risk: hosting policy gated on monetization intent",
381
+ "status": "pending",
382
+ "narrative": {
383
+ "Description": (
384
+ "Document the hosting plan and gate it on the captured "
385
+ "monetization intent. Self-hosted private use is the "
386
+ "default; commercial hosting requires lawyer review."
387
+ ),
388
+ "Acceptance": hosting_acceptance,
389
+ "Traces": "IP-3",
390
+ },
391
+ },
392
+ ]
393
+
394
+
395
+ def plain_risk_summary(
396
+ hits: list[IPHit],
397
+ monetization_intent: str,
398
+ ) -> str:
399
+ """Build a plain-English risk summary suitable for interview output.
400
+
401
+ The summary is intentionally non-alarming and non-legal-advice: it
402
+ states what was detected, what the implication is at a high level,
403
+ and (for commercial intent) it carries a non-optional recommendation
404
+ to consult a lawyer. The summary is meant to be copied verbatim into
405
+ the interview output and into the ``IPRisk`` narrative on the
406
+ specification vBRIEF.
407
+
408
+ Returns an empty string when *hits* is empty (no IP detected); the
409
+ caller MUST NOT inject the summary in that case.
410
+
411
+ .. warning::
412
+
413
+ ``monetization_intent="unknown"`` produces a **transitional**
414
+ re-ask prompt -- a status message saying "the interview MUST
415
+ capture an explicit answer". It is NOT a terminal output and
416
+ does NOT carry the lawyer recommendation that
417
+ :func:`ip_risk_scope_items` injects under the wrong-side-of-safe
418
+ policy. Callers MUST loop back to the monetization-intent
419
+ question and re-call this function with ``personal`` or
420
+ ``commercial`` before treating the summary as final output --
421
+ otherwise the interview surface (no lawyer rec) and the
422
+ injected spec items (commercial-level acceptance language)
423
+ will mismatch (#775 P2).
424
+ """
425
+ if not hits:
426
+ return ""
427
+ intent = _validate_intent(monetization_intent)
428
+
429
+ grouped: dict[str, list[str]] = {}
430
+ for hit in hits:
431
+ grouped.setdefault(hit.category, []).append(hit.term)
432
+
433
+ bullets: list[str] = []
434
+ for category, terms in grouped.items():
435
+ unique_terms = sorted(set(terms), key=str.lower)
436
+ bullets.append(f"- {category}: {', '.join(unique_terms)}")
437
+
438
+ header = (
439
+ "Heads up: your project description references third-party "
440
+ "intellectual property (IP). This is a plain-English summary -- "
441
+ "not legal advice."
442
+ )
443
+ detection_block = "Detected IP-adjacent terms:\n" + "\n".join(bullets)
444
+
445
+ if intent == "commercial":
446
+ intent_block = (
447
+ "You said you intend to use this commercially (sell access, "
448
+ "earn revenue, distribute to paying users, or run ads). "
449
+ "Commercial use of someone else's IP without a written license "
450
+ "is the high-risk case. You MUST consult a lawyer before "
451
+ "shipping to paying users -- this is not optional output from "
452
+ "this interview."
453
+ )
454
+ elif intent == "personal":
455
+ intent_block = (
456
+ "You said this is a personal project (no monetization, private "
457
+ "use, learning). Personal use is lower risk but not zero risk: "
458
+ "if your project ever goes public, becomes monetized, or is "
459
+ "shared widely, the risk profile changes and a lawyer review "
460
+ "becomes worthwhile."
461
+ )
462
+ else: # unknown
463
+ intent_block = (
464
+ "You did not choose between personal and commercial use. The "
465
+ "interview MUST capture an explicit answer before generating "
466
+ "the SPECIFICATION -- the legal-risk profile depends on the "
467
+ "answer."
468
+ )
469
+
470
+ next_steps = (
471
+ "Suggested next steps: (1) confirm whether your use is personal "
472
+ "or commercial; (2) keep the disclaimer / API-only-asset / "
473
+ "hosting scope items the SPECIFICATION will include; "
474
+ "(3) for commercial intent, consult a lawyer before public "
475
+ "release."
476
+ )
477
+
478
+ return "\n\n".join([header, detection_block, intent_block, next_steps])
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # CLI entry point (for ad-hoc use; the canonical caller is the interview
483
+ # skill which invokes the helpers directly).
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def main(argv: list[str] | None = None) -> int:
488
+ args = sys.argv[1:] if argv is None else argv
489
+ if not args:
490
+ print(
491
+ "Usage: ip_risk.py <text-to-scan> [--intent personal|commercial|unknown]",
492
+ file=sys.stderr,
493
+ )
494
+ return 2
495
+
496
+ intent = "unknown"
497
+ text_parts: list[str] = []
498
+ i = 0
499
+ while i < len(args):
500
+ arg = args[i]
501
+ if arg == "--intent" and i + 1 < len(args):
502
+ intent = args[i + 1]
503
+ i += 2
504
+ continue
505
+ text_parts.append(arg)
506
+ i += 1
507
+ text = " ".join(text_parts)
508
+ if not text:
509
+ print("Usage: ip_risk.py <text-to-scan>", file=sys.stderr)
510
+ return 2
511
+
512
+ hits = detect_ip_terms(text)
513
+ if not hits:
514
+ print("No IP terms detected.")
515
+ return 0
516
+
517
+ print("Detected IP terms:")
518
+ for hit in hits:
519
+ print(f" - {hit}")
520
+ try:
521
+ summary = plain_risk_summary(hits, intent)
522
+ except ValueError as exc:
523
+ print(f"Invalid --intent value: {exc}", file=sys.stderr)
524
+ return 2
525
+ print()
526
+ print(summary)
527
+ return 1
528
+
529
+
530
+ if __name__ == "__main__": # pragma: no cover - CLI entry
531
+ sys.exit(main())