@deftai/directive-content 0.58.0 → 0.60.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 (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. 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())