@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.
- package/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- 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())
|