@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,293 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- project_render.py — Regenerate PROJECT-DEFINITION.vbrief.json from lifecycle folders.
4
-
5
- Deterministic layer (RFC #309, Decision D14):
6
- - Scans all lifecycle folders (proposed/, pending/, active/, completed/, cancelled/)
7
- - Updates the items registry with scope entries (title, status, file path, references)
8
- - Timestamps freshness (vBRIEFInfo.updated)
9
- - Flags narratives that may be stale based on completed scope topics
10
- - Creates skeleton PROJECT-DEFINITION.vbrief.json if none exists
11
-
12
- Agent-assisted layer (documented convention, not implemented as code):
13
- During sync or refinement sessions, the agent reviews flagged narratives and
14
- proposes updates to project identity (overview, capabilities, risks, tech stack)
15
- based on completed work. The user approves -- never fully automatic for content
16
- requiring judgment.
17
-
18
- Workflow:
19
- 1. Run `task project:render` to refresh the items registry and staleness flags.
20
- 2. The agent reads staleness_flags from plan.metadata.staleness_flags.
21
- 3. For each flagged narrative, the agent drafts a proposed update reflecting
22
- the completed scopes (e.g. if a "tech stack" scope completed, update the
23
- TechStack narrative with the new technology choices).
24
- 4. The user reviews and approves each narrative change.
25
- 5. The agent writes approved changes back to PROJECT-DEFINITION.vbrief.json.
26
-
27
- Usage:
28
- uv run python scripts/project_render.py [vbrief_dir]
29
-
30
- vbrief_dir — path to vbrief/ directory (default: ./vbrief)
31
-
32
- Exit codes:
33
- 0 — rendered successfully
34
- 1 — error occurred
35
- 2 — usage error
36
- """
37
-
38
- import json
39
- import re
40
- import sys
41
- from datetime import UTC, datetime
42
- from pathlib import Path
43
-
44
- # Make sibling scripts importable both when run as __main__ and when imported
45
- # by tests that pre-populate sys.path with the ``scripts/`` directory.
46
- sys.path.insert(0, str(Path(__file__).resolve().parent))
47
-
48
- # UTF-8 stdout guard (#540).
49
- from _stdio_utf8 import reconfigure_stdio # noqa: E402
50
-
51
- reconfigure_stdio()
52
-
53
- from _vbrief_build import ( # noqa: E402
54
- EMITTED_VBRIEF_VERSION as _EMITTED_VBRIEF_VERSION,
55
- )
56
-
57
- LIFECYCLE_FOLDERS = ("proposed", "pending", "active", "completed", "cancelled")
58
-
59
- # Keys intentionally match scripts/vbrief_validate.py PROJECT_DEF_EXPECTED_NARRATIVES
60
- # after case-folding: "overview" and "tech stack" are required by D3 (#405).
61
- # Keep the "tech stack" key exactly as-is (lowercase, space-separated) so
62
- # `task project:render` skeletons pass `task vbrief:validate` immediately.
63
- SKELETON_NARRATIVES = {
64
- "Overview": "",
65
- "tech stack": "",
66
- "Architecture": "",
67
- "RisksAndUnknowns": "",
68
- "Configuration": "",
69
- }
70
-
71
-
72
- def _split_camel(name: str) -> list[str]:
73
- """Split a camelCase or PascalCase string into lowercase words.
74
-
75
- >>> _split_camel("TechStack")
76
- ['tech', 'stack']
77
- >>> _split_camel("RisksAndUnknowns")
78
- ['risks', 'and', 'unknowns']
79
- """
80
- parts = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
81
- return [w.lower() for w in parts.split()]
82
-
83
-
84
- def scan_lifecycle_folders(vbrief_dir: Path) -> list[dict]:
85
- """Scan all lifecycle folders for *.vbrief.json files and return items.
86
-
87
- Scans folders in a fixed order (proposed, pending, active, completed,
88
- cancelled) and files alphabetically within each folder, producing
89
- deterministic output for the same folder contents.
90
- """
91
- items: list[dict] = []
92
- for folder_name in LIFECYCLE_FOLDERS:
93
- folder = vbrief_dir / folder_name
94
- if not folder.is_dir():
95
- continue
96
- for vbrief_file in sorted(folder.glob("*.vbrief.json")):
97
- try:
98
- with open(vbrief_file, encoding="utf-8") as fh:
99
- data = json.load(fh)
100
- plan = data.get("plan", {})
101
- title = plan.get("title", vbrief_file.stem)
102
- status = plan.get("status", folder_name)
103
- references = plan.get("references", [])
104
-
105
- item: dict = {
106
- "id": vbrief_file.stem.replace(".vbrief", ""),
107
- "title": title,
108
- "status": status,
109
- "metadata": {
110
- "source_path": f"{folder_name}/{vbrief_file.name}",
111
- "lifecycle_folder": folder_name,
112
- },
113
- }
114
- if references:
115
- item["metadata"]["references"] = references
116
- items.append(item)
117
- except (json.JSONDecodeError, OSError):
118
- items.append(
119
- {
120
- "id": vbrief_file.stem.replace(".vbrief", ""),
121
- "title": f"[unreadable] {vbrief_file.name}",
122
- "status": "draft",
123
- "metadata": {
124
- "source_path": f"{folder_name}/{vbrief_file.name}",
125
- "lifecycle_folder": folder_name,
126
- "error": "Failed to read or parse file",
127
- },
128
- }
129
- )
130
- return items
131
-
132
-
133
- def flag_stale_narratives(
134
- narratives: dict[str, str],
135
- completed_items: list[dict],
136
- ) -> list[str]:
137
- """Flag narratives that may need review based on completed scope topics.
138
-
139
- Algorithm (deterministic):
140
- 1. Split each narrative key into words (camelCase-aware).
141
- 2. For each completed scope, extract title words.
142
- 3. If any narrative-key word (>3 chars) appears in a completed scope title,
143
- flag that narrative with the matching scope.
144
- 4. If >=3 completed scopes exist and no specific flags fired, emit a general
145
- review recommendation.
146
-
147
- Returns a sorted list of staleness warning strings.
148
- """
149
- if not completed_items or not narratives:
150
- if completed_items and len(completed_items) >= 3:
151
- return [
152
- f"{len(completed_items)} scopes completed since last narrative update"
153
- " -- review recommended"
154
- ]
155
- return []
156
-
157
- flags: list[str] = []
158
- flagged_narratives: set[str] = set()
159
-
160
- for narrative_key in sorted(narratives.keys()):
161
- key_words = {w for w in _split_camel(narrative_key) if len(w) > 3}
162
- if not key_words:
163
- continue
164
- for item in completed_items:
165
- title_lower = item.get("title", "").lower()
166
- title_words = set(re.split(r"\W+", title_lower))
167
- overlap = key_words & title_words
168
- if overlap:
169
- flags.append(
170
- f"Narrative '{narrative_key}' may be stale: "
171
- f"completed scope '{item.get('title', '')}' "
172
- f"shares topics ({', '.join(sorted(overlap))})"
173
- )
174
- flagged_narratives.add(narrative_key)
175
-
176
- # General flag if many completed scopes but no specific matches
177
- if len(completed_items) >= 3 and not flagged_narratives:
178
- flags.append(
179
- f"{len(completed_items)} scopes completed since last narrative update"
180
- " -- review recommended"
181
- )
182
-
183
- return sorted(flags)
184
-
185
-
186
- def create_skeleton(items: list[dict], now: str) -> dict:
187
- """Create a skeleton PROJECT-DEFINITION.vbrief.json structure."""
188
- completed_items = [i for i in items if i.get("status") == "completed"]
189
- staleness_flags = flag_stale_narratives(dict(SKELETON_NARRATIVES), completed_items)
190
-
191
- return {
192
- "vBRIEFInfo": {
193
- # #533: match the migrator's emitted version so skeletons
194
- # produced by ``task project:render`` round-trip through the
195
- # validator during the v0.6 transition. Sourced from the
196
- # shared constant in _vbrief_build so a future bump lands in
197
- # one place.
198
- "version": _EMITTED_VBRIEF_VERSION,
199
- "description": "Project definition -- synthesized gestalt of the project",
200
- "created": now,
201
- "updated": now,
202
- },
203
- "plan": {
204
- "title": "PROJECT-DEFINITION",
205
- "status": "running",
206
- "narratives": dict(SKELETON_NARRATIVES),
207
- "items": items,
208
- "metadata": {
209
- "staleness_flags": staleness_flags,
210
- },
211
- },
212
- }
213
-
214
-
215
- def render_project_definition(vbrief_dir: str) -> tuple[bool, str]:
216
- """Regenerate PROJECT-DEFINITION.vbrief.json from lifecycle folder contents.
217
-
218
- Returns:
219
- (True, success_message) on success.
220
- (False, error_message) on failure.
221
- """
222
- vbrief_path = Path(vbrief_dir)
223
- project_def_path = vbrief_path / "PROJECT-DEFINITION.vbrief.json"
224
-
225
- # Scan lifecycle folders (handles missing folders gracefully)
226
- items = scan_lifecycle_folders(vbrief_path)
227
-
228
- now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
229
- created_new = not project_def_path.exists()
230
-
231
- if project_def_path.exists():
232
- # Update existing PROJECT-DEFINITION
233
- try:
234
- with open(project_def_path, encoding="utf-8") as fh:
235
- project_def = json.load(fh)
236
- except (json.JSONDecodeError, OSError) as exc:
237
- return False, f"✗ Failed to read {project_def_path}: {exc}"
238
-
239
- plan = project_def.get("plan", {})
240
-
241
- # Update items registry (deterministic)
242
- plan["items"] = items
243
-
244
- # Timestamp freshness
245
- project_def.setdefault("vBRIEFInfo", {})
246
- project_def["vBRIEFInfo"]["updated"] = now
247
-
248
- # Flag stale narratives
249
- narratives = plan.get("narratives", {})
250
- completed_items = [i for i in items if i.get("status") == "completed"]
251
- flags = flag_stale_narratives(narratives, completed_items)
252
- plan.setdefault("metadata", {})
253
- plan["metadata"]["staleness_flags"] = flags
254
-
255
- project_def["plan"] = plan
256
- else:
257
- # Create skeleton
258
- project_def = create_skeleton(items, now)
259
-
260
- # Ensure parent directory exists
261
- project_def_path.parent.mkdir(parents=True, exist_ok=True)
262
-
263
- # Write deterministic output
264
- with open(project_def_path, "w", encoding="utf-8") as fh:
265
- json.dump(project_def, fh, indent=2, ensure_ascii=False)
266
- fh.write("\n")
267
-
268
- # Report
269
- item_count = len(items)
270
- flag_count = len(project_def["plan"].get("metadata", {}).get("staleness_flags", []))
271
- action = "created" if created_new else "updated"
272
-
273
- parts = [f"✓ PROJECT-DEFINITION.vbrief.json {action} ({item_count} scope items)"]
274
- if flag_count:
275
- parts.append(f"⚠ {flag_count} staleness flag(s) -- agent review recommended")
276
-
277
- return True, "\n".join(parts)
278
-
279
-
280
- def main() -> int:
281
- if len(sys.argv) > 2:
282
- print("Usage: project_render.py [vbrief_dir]", file=sys.stderr)
283
- return 2
284
-
285
- vbrief_dir = sys.argv[1] if len(sys.argv) == 2 else "vbrief"
286
-
287
- ok, message = render_project_definition(vbrief_dir)
288
- print(message)
289
- return 0 if ok else 1
290
-
291
-
292
- if __name__ == "__main__":
293
- sys.exit(main())
@@ -1,237 +0,0 @@
1
- #!/usr/bin/env python3
2
- r"""quarantine_ext.py -- prompt-injection quarantine for cached issue bodies (#583).
3
-
4
- Public surface
5
- --------------
6
-
7
- ``quarantine_body(raw_md: str) -> str``
8
- Return ``raw_md`` with any injection-shaped sections wrapped in
9
- ``\`\`\`quarantined`` fenced code blocks. Idempotent: input already wrapped
10
- in ``quarantined`` fences is left unchanged.
11
-
12
- Background
13
- ----------
14
-
15
- Issue bodies on GitHub frequently contain imperative-shaped markdown that
16
- looks like agent instructions (``# STEP 1``, ``## TASK:``, ``IMPORTANT:`` /
17
- ``MUST`` headings, ``SYSTEM:`` directives, etc.). When a downstream agent
18
- reads a cached issue body verbatim, the text is *data*, not *instructions* --
19
- but a careless prompt template can splice the body directly into the agent's
20
- turn payload, allowing a hostile issue author to redirect the agent.
21
-
22
- #583 codified the mitigation: the cache layer wraps suspicious sections in a
23
- ``\`\`\`quarantined`` fenced code block so downstream consumers can detect and
24
- either strip the section or emit a clear `do not follow these instructions`
25
- preamble around it. The fence label is intentionally a non-standard
26
- language-id so it is a syntactic marker, not a renderable hint.
27
-
28
- Heuristic
29
- ---------
30
-
31
- A markdown heading line (``^#{1,6} +``) is considered *suspicious* when it
32
- contains one of the imperative tokens listed in :data:`SUSPICIOUS_TOKENS`
33
- (case-insensitive, word-boundary scoped). Every line from a suspicious
34
- heading down to (but not including) the next heading -- or end of document --
35
- is treated as the suspicious section and wrapped.
36
-
37
- Non-heading injection patterns (e.g. ``IMPORTANT:`` or ``SYSTEM:`` on a
38
- plain prose line) also trigger wrapping of that line so a one-shot directive
39
- embedded in body prose is still flagged.
40
-
41
- The heuristic is intentionally permissive (false-positives wrap benign
42
- ``## Steps to reproduce`` sections in a quarantined fence). The downstream
43
- display layer is responsible for unwrapping legitimate sections; the cost
44
- of a false positive is one extra fence in the rendered output, while the
45
- cost of a false negative is an exfiltrated agent turn.
46
-
47
- CLI
48
- ---
49
-
50
- The module is callable as a script:
51
-
52
- python scripts/quarantine_ext.py [<input-file>]
53
-
54
- Reads ``<input-file>`` if given, otherwise stdin, and writes the quarantined
55
- markdown to stdout. Useful for ad-hoc inspection.
56
- """
57
-
58
- from __future__ import annotations
59
-
60
- import re
61
- import sys
62
- from pathlib import Path
63
-
64
- # Imperative tokens that mark a heading or line as injection-shaped. The set
65
- # is curated against the recurrence record in #583 plus the canonical
66
- # agent-prompt vocabulary used by Warp / Oz / Claude / OpenAI tool surfaces.
67
- # Word-boundary scoped so the substring ``step`` inside ``stepladder`` does
68
- # NOT trigger.
69
- SUSPICIOUS_TOKENS: tuple[str, ...] = (
70
- "STEP",
71
- "TASK:",
72
- "TASK ",
73
- "IMPORTANT:",
74
- "IMPORTANT ",
75
- "MUST",
76
- "SYSTEM:",
77
- "SYSTEM ",
78
- "AGENT:",
79
- "AGENT ",
80
- "ASSISTANT:",
81
- "USER:",
82
- "INSTRUCTION:",
83
- "INSTRUCTIONS:",
84
- "TOOL:",
85
- "FUNCTION:",
86
- "PROMPT:",
87
- "OVERRIDE:",
88
- "IGNORE PREVIOUS",
89
- "DISREGARD PREVIOUS",
90
- "FORGET PREVIOUS",
91
- "ROLE:",
92
- "DIRECTIVE:",
93
- )
94
-
95
- # Regex source of truth -- compiled once. Word-boundary on each side of the
96
- # token, except for tokens that already include trailing punctuation
97
- # (``TASK:`` etc.) where the punctuation acts as the boundary.
98
- _TOKEN_PATTERNS = []
99
- for _tok in SUSPICIOUS_TOKENS:
100
- if _tok.endswith((":", " ")):
101
- # punctuation-anchored -- no trailing \b (the colon/space is the boundary)
102
- _TOKEN_PATTERNS.append(r"\b" + re.escape(_tok))
103
- else:
104
- _TOKEN_PATTERNS.append(r"\b" + re.escape(_tok) + r"\b")
105
- _TOKEN_RE = re.compile("|".join(_TOKEN_PATTERNS), re.IGNORECASE)
106
-
107
- # Heading detector: 1-6 hashes followed by at least one space. Setext-style
108
- # headings (=== / ---) are intentionally not detected because they require
109
- # multi-line lookahead and are vanishingly rare in GitHub-flavoured-markdown
110
- # issue bodies.
111
- _HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S.*)$")
112
-
113
- # A code-fence delimiter line. Tracks whether we are inside an existing
114
- # code block so heading-shaped text inside ```text``` is not re-quarantined.
115
- _FENCE_RE = re.compile(r"^(```|~~~)")
116
-
117
- QUARANTINE_FENCE_OPEN = "```quarantined"
118
- QUARANTINE_FENCE_CLOSE = "```"
119
-
120
-
121
- def _is_suspicious(line: str) -> bool:
122
- return bool(_TOKEN_RE.search(line))
123
-
124
-
125
- def _is_heading(line: str) -> bool:
126
- return bool(_HEADING_RE.match(line))
127
-
128
-
129
- def quarantine_body(raw_md: str) -> str:
130
- r"""Wrap injection-shaped sections in ``\`\`\`quarantined`` fences.
131
-
132
- Args:
133
- raw_md: Raw markdown body (e.g. the rendered text of a GitHub issue
134
- body fetched via ``gh issue view --json body``).
135
-
136
- Returns:
137
- The same markdown with suspicious sections wrapped. If no sections
138
- match, the input is returned unchanged (modulo trailing-newline
139
- normalization).
140
-
141
- The function is idempotent: re-running on already-quarantined text is a
142
- no-op because the existing ``\`\`\`quarantined`` fence is recognised as
143
- a code block and its contents are skipped.
144
- """
145
- if not raw_md:
146
- return raw_md
147
-
148
- lines = raw_md.splitlines()
149
- out: list[str] = []
150
- i = 0
151
- in_fence: str | None = None # the fence delimiter we are inside, if any
152
-
153
- while i < len(lines):
154
- line = lines[i]
155
-
156
- # Track existing fenced code blocks so we don't re-wrap them.
157
- # ``in_fence`` records the opening delimiter; we only close on a
158
- # matching delimiter (Greptile P1: previously closed on the
159
- # current line's delim, which let a ``~~~`` line close an open
160
- # ``\`\`\`` fence and reopen a new one, leaving suspicious headings
161
- # after that point unquarantined).
162
- fence_match = _FENCE_RE.match(line)
163
- if fence_match:
164
- delim = fence_match.group(1)
165
- if in_fence is None:
166
- in_fence = delim
167
- elif line.startswith(in_fence):
168
- in_fence = None
169
- out.append(line)
170
- i += 1
171
- continue
172
- if in_fence is not None:
173
- out.append(line)
174
- i += 1
175
- continue
176
-
177
- # Suspicious heading: capture from this line to (but not including)
178
- # the next heading -- regardless of whether the next heading is also
179
- # suspicious. The next iteration will re-wrap the next section if
180
- # needed.
181
- if _is_heading(line) and _is_suspicious(line):
182
- section_end = i + 1
183
- while section_end < len(lines):
184
- nxt = lines[section_end]
185
- if _FENCE_RE.match(nxt):
186
- # do not split a quarantined block across an unbalanced
187
- # fence -- consume the entire interior. Both ``\`\`\``
188
- # and ``~~~`` are 3-char delimiters; we slice the same
189
- # prefix length and match the literal opener (Greptile
190
- # P3: dead-conditional cleanup).
191
- section_end += 1
192
- nested = nxt[:3]
193
- while section_end < len(lines) and not lines[
194
- section_end
195
- ].startswith(nested):
196
- section_end += 1
197
- section_end += 1 # consume the closer
198
- continue
199
- if _is_heading(nxt):
200
- break
201
- section_end += 1
202
- out.append(QUARANTINE_FENCE_OPEN)
203
- out.extend(lines[i:section_end])
204
- out.append(QUARANTINE_FENCE_CLOSE)
205
- i = section_end
206
- continue
207
-
208
- # Suspicious non-heading line: wrap just that line.
209
- if _is_suspicious(line):
210
- out.append(QUARANTINE_FENCE_OPEN)
211
- out.append(line)
212
- out.append(QUARANTINE_FENCE_CLOSE)
213
- i += 1
214
- continue
215
-
216
- out.append(line)
217
- i += 1
218
-
219
- # Preserve trailing newline behaviour of the input. If the input ends
220
- # with a newline, splitlines() drops it; re-add for round-trip safety.
221
- suffix = "\n" if raw_md.endswith("\n") else ""
222
- return "\n".join(out) + suffix
223
-
224
-
225
- def main(argv: list[str] | None = None) -> int:
226
- """CLI entry point. Reads input file (or stdin) and emits quarantined md."""
227
- argv = list(argv if argv is not None else sys.argv[1:])
228
- if argv and argv[0] in {"-h", "--help"}:
229
- print(__doc__ or "")
230
- return 0
231
- text = Path(argv[0]).read_text(encoding="utf-8") if argv else sys.stdin.read()
232
- sys.stdout.write(quarantine_body(text))
233
- return 0
234
-
235
-
236
- if __name__ == "__main__":
237
- raise SystemExit(main())