ruby_reactor 0.5.4 → 0.6.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
  3. data/.claude/skills/speckit-analyze/SKILL.md +262 -0
  4. data/.claude/skills/speckit-checklist/SKILL.md +374 -0
  5. data/.claude/skills/speckit-clarify/SKILL.md +286 -0
  6. data/.claude/skills/speckit-constitution/SKILL.md +157 -0
  7. data/.claude/skills/speckit-converge/SKILL.md +277 -0
  8. data/.claude/skills/speckit-implement/SKILL.md +224 -0
  9. data/.claude/skills/speckit-plan/SKILL.md +171 -0
  10. data/.claude/skills/speckit-specify/SKILL.md +346 -0
  11. data/.claude/skills/speckit-tasks/SKILL.md +215 -0
  12. data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
  13. data/.release-please-manifest.json +1 -1
  14. data/.specify/extensions/.registry +19 -0
  15. data/.specify/extensions/agent-context/README.md +66 -0
  16. data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
  17. data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
  18. data/.specify/extensions/agent-context/extension.yml +34 -0
  19. data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
  20. data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
  21. data/.specify/extensions.yml +23 -0
  22. data/.specify/feature.json +3 -0
  23. data/.specify/init-options.json +9 -0
  24. data/.specify/integration.json +15 -0
  25. data/.specify/integrations/claude.manifest.json +17 -0
  26. data/.specify/integrations/speckit.manifest.json +17 -0
  27. data/.specify/memory/constitution.md +134 -0
  28. data/.specify/scripts/bash/check-prerequisites.sh +189 -0
  29. data/.specify/scripts/bash/common.sh +619 -0
  30. data/.specify/scripts/bash/create-new-feature.sh +299 -0
  31. data/.specify/scripts/bash/setup-plan.sh +84 -0
  32. data/.specify/scripts/bash/setup-tasks.sh +91 -0
  33. data/.specify/templates/checklist-template.md +40 -0
  34. data/.specify/templates/constitution-template.md +50 -0
  35. data/.specify/templates/plan-template.md +113 -0
  36. data/.specify/templates/spec-template.md +131 -0
  37. data/.specify/templates/tasks-template.md +252 -0
  38. data/.specify/workflows/speckit/workflow.yml +77 -0
  39. data/.specify/workflows/workflow-registry.json +13 -0
  40. data/CHANGELOG.md +7 -0
  41. data/README.md +51 -24
  42. data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
  43. data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
  44. data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
  45. data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
  46. data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
  47. data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
  48. data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
  49. data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
  50. data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
  51. data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
  52. data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
  53. data/lib/ruby_reactor/configuration.rb +24 -4
  54. data/lib/ruby_reactor/map/element_executor.rb +1 -1
  55. data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
  56. data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
  57. data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
  58. data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
  59. data/lib/ruby_reactor/rspec.rb +4 -0
  60. data/lib/ruby_reactor/sweeper_job.rb +70 -0
  61. data/lib/ruby_reactor/version.rb +1 -1
  62. data/lib/ruby_reactor/worker.rb +226 -0
  63. data/lib/ruby_reactor.rb +40 -1
  64. data/specs/active_job.md +259 -0
  65. metadata +54 -6
  66. data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
  67. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
  68. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
  69. data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
  70. data/lib/ruby_reactor/sidekiq_workers/worker.rb +0 -222
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env bash
2
+ # update-agent-context.sh
3
+ #
4
+ # Refresh the managed Spec Kit section in the coding agent's context file(s)
5
+ # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
6
+ #
7
+ # Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
8
+ # agent-context extension config:
9
+ # .specify/extensions/agent-context/agent-context-config.yml
10
+ #
11
+ # Usage: update-agent-context.sh [plan_path]
12
+ #
13
+ # When `plan_path` is omitted, the script picks the most recently modified
14
+ # `specs/*/plan.md` if any exist, otherwise emits the section without a
15
+ # concrete plan path.
16
+
17
+ set -euo pipefail
18
+
19
+ PROJECT_ROOT="$(pwd)"
20
+ EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
21
+ DEFAULT_START="<!-- SPECKIT START -->"
22
+ DEFAULT_END="<!-- SPECKIT END -->"
23
+
24
+ if [[ ! -f "$EXT_CONFIG" ]]; then
25
+ echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
26
+ exit 0
27
+ fi
28
+
29
+ # Locate a Python 3 interpreter with PyYAML available.
30
+ _python=""
31
+ _python_candidates=()
32
+ [[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON")
33
+ _python_candidates+=("python3" "python")
34
+ for _candidate in "${_python_candidates[@]}"; do
35
+ if command -v "$_candidate" >/dev/null 2>&1 \
36
+ && "$_candidate" - <<'PY' >/dev/null 2>&1
37
+ import sys
38
+ try:
39
+ import yaml # noqa: F401
40
+ except ImportError:
41
+ sys.exit(1)
42
+ sys.exit(0 if sys.version_info[0] == 3 else 1)
43
+ PY
44
+ then
45
+ _python="$_candidate"
46
+ break
47
+ fi
48
+ done
49
+ unset _candidate _python_candidates
50
+
51
+ if [[ -z "$_python" ]]; then
52
+ echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2
53
+ echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2
54
+ exit 0
55
+ fi
56
+ _case_insensitive_context_files=0
57
+ case "$(uname -s 2>/dev/null || true)" in
58
+ MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;;
59
+ esac
60
+
61
+ # Parse extension config once; emit context files as JSON, followed by marker strings.
62
+ if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
63
+ import json
64
+ import sys
65
+ try:
66
+ import yaml
67
+ except ImportError:
68
+ print(
69
+ "agent-context: PyYAML is required to parse extension config but is not available "
70
+ "in the current Python environment.\n"
71
+ " To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
72
+ " Context file will not be updated until PyYAML is importable.",
73
+ file=sys.stderr,
74
+ )
75
+ sys.exit(2)
76
+ try:
77
+ with open(sys.argv[1], "r", encoding="utf-8") as fh:
78
+ data = yaml.safe_load(fh)
79
+ except Exception as exc:
80
+ print(
81
+ f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
82
+ file=sys.stderr,
83
+ )
84
+ sys.exit(2)
85
+ if not isinstance(data, dict):
86
+ data = {}
87
+ def get_str(obj, *keys):
88
+ node = obj
89
+ for k in keys:
90
+ if isinstance(node, dict) and k in node:
91
+ node = node[k]
92
+ else:
93
+ return ""
94
+ return node if isinstance(node, str) else ""
95
+ context_files = []
96
+ seen_context_files = set()
97
+ case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
98
+ raw_files = data.get("context_files")
99
+ if isinstance(raw_files, list):
100
+ for value in raw_files:
101
+ if not isinstance(value, str):
102
+ continue
103
+ candidate = value.strip()
104
+ if not candidate:
105
+ continue
106
+ key = candidate.casefold() if case_insensitive else candidate
107
+ if key in seen_context_files:
108
+ continue
109
+ context_files.append(candidate)
110
+ seen_context_files.add(key)
111
+ if not context_files:
112
+ raw_file = get_str(data, "context_file")
113
+ candidate = raw_file.strip()
114
+ if candidate:
115
+ context_files.append(candidate)
116
+ print(json.dumps(context_files))
117
+ print(get_str(data, "context_markers", "start"))
118
+ print(get_str(data, "context_markers", "end"))
119
+ PY
120
+ )"; then
121
+ echo "agent-context: skipping update (see above for details)." >&2
122
+ exit 0
123
+ fi
124
+
125
+ _opts_lines=()
126
+ while IFS= read -r _line || [[ -n "$_line" ]]; do
127
+ _opts_lines+=("$_line")
128
+ done < <(printf '%s\n' "$_raw_opts")
129
+ if (( ${#_opts_lines[@]} < 3 )); then
130
+ echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
131
+ exit 0
132
+ fi
133
+ CONTEXT_FILES_JSON="${_opts_lines[0]}"
134
+ MARKER_START="${_opts_lines[1]}"
135
+ MARKER_END="${_opts_lines[2]}"
136
+
137
+ if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
138
+ import json
139
+ import sys
140
+ try:
141
+ data = json.loads(sys.argv[1])
142
+ except Exception:
143
+ data = []
144
+ if not isinstance(data, list):
145
+ data = []
146
+ for value in data:
147
+ if isinstance(value, str) and value:
148
+ print(value)
149
+ PY
150
+ )"; then
151
+ echo "agent-context: malformed context_files parser output; skipping update." >&2
152
+ exit 0
153
+ fi
154
+
155
+ CONTEXT_FILES=()
156
+ while IFS= read -r _line || [[ -n "$_line" ]]; do
157
+ [[ -n "$_line" ]] && CONTEXT_FILES+=("$_line")
158
+ done < <(printf '%s\n' "$_context_files_raw")
159
+
160
+ if (( ${#CONTEXT_FILES[@]} == 0 )); then
161
+ echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2
162
+ exit 0
163
+ fi
164
+
165
+ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
166
+ # Reject absolute paths, backslash separators, and '..' path segments in context files
167
+ if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
168
+ echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2
169
+ exit 1
170
+ fi
171
+ if [[ "$CONTEXT_FILE" == *\\* ]]; then
172
+ echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2
173
+ exit 1
174
+ fi
175
+ IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
176
+ for _seg in "${_cf_parts[@]}"; do
177
+ if [[ "$_seg" == ".." ]]; then
178
+ echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
179
+ exit 1
180
+ fi
181
+ done
182
+ if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY'
183
+ import sys
184
+ from pathlib import Path
185
+
186
+ root = Path(sys.argv[1]).resolve()
187
+ target = (root / sys.argv[2]).resolve(strict=False)
188
+ try:
189
+ target.relative_to(root)
190
+ except ValueError:
191
+ sys.exit(1)
192
+ PY
193
+ then
194
+ echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2
195
+ exit 1
196
+ fi
197
+ done
198
+ unset _cf_parts _seg
199
+
200
+ [[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
201
+ [[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
202
+
203
+ PLAN_PATH="${1:-}"
204
+ if [[ -z "$PLAN_PATH" ]]; then
205
+ # Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
206
+ # Use find + sort by modification time to avoid ls/head fragility with
207
+ # spaces in paths or SIGPIPE from pipefail.
208
+ _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
209
+ import sys, os
210
+ from pathlib import Path
211
+ specs = Path(sys.argv[1]) / "specs"
212
+ plans = sorted(
213
+ specs.glob("*/plan.md"),
214
+ key=lambda p: p.stat().st_mtime,
215
+ reverse=True,
216
+ )
217
+ print(plans[0] if plans else "")
218
+ PY
219
+ )"
220
+ if [[ -n "$_plan_abs" ]]; then
221
+ PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
222
+ fi
223
+ fi
224
+
225
+ # Build the managed section
226
+ TMP_SECTION="$(mktemp)"
227
+ trap 'rm -f "$TMP_SECTION"' EXIT
228
+ {
229
+ echo "$MARKER_START"
230
+ echo "For additional context about technologies to be used, project structure,"
231
+ echo "shell commands, and other important information, read the current plan"
232
+ if [[ -n "$PLAN_PATH" ]]; then
233
+ echo "at $PLAN_PATH"
234
+ fi
235
+ echo "$MARKER_END"
236
+ } > "$TMP_SECTION"
237
+
238
+ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
239
+ CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
240
+ mkdir -p "$(dirname "$CTX_PATH")"
241
+
242
+ "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
243
+ import sys, os
244
+ ctx_path, start, end, section_path = sys.argv[1:5]
245
+ with open(section_path, "r", encoding="utf-8") as fh:
246
+ section = fh.read().rstrip("\n") + "\n"
247
+
248
+ if os.path.exists(ctx_path):
249
+ with open(ctx_path, "r", encoding="utf-8-sig") as fh:
250
+ content = fh.read()
251
+ s = content.find(start)
252
+ e = content.find(end, s if s != -1 else 0)
253
+ if s != -1 and e != -1 and e > s:
254
+ end_of_marker = e + len(end)
255
+ if end_of_marker < len(content) and content[end_of_marker] == "\r":
256
+ end_of_marker += 1
257
+ if end_of_marker < len(content) and content[end_of_marker] == "\n":
258
+ end_of_marker += 1
259
+ new_content = content[:s] + section + content[end_of_marker:]
260
+ elif s != -1:
261
+ new_content = content[:s] + section
262
+ elif e != -1:
263
+ end_of_marker = e + len(end)
264
+ if end_of_marker < len(content) and content[end_of_marker] == "\r":
265
+ end_of_marker += 1
266
+ if end_of_marker < len(content) and content[end_of_marker] == "\n":
267
+ end_of_marker += 1
268
+ new_content = section + content[end_of_marker:]
269
+ else:
270
+ if content and not content.endswith("\n"):
271
+ content += "\n"
272
+ new_content = (content + "\n" + section) if content else section
273
+ else:
274
+ new_content = section
275
+
276
+ new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
277
+ with open(ctx_path, "wb") as fh:
278
+ fh.write(new_content.encode("utf-8"))
279
+ PY
280
+
281
+ echo "agent-context: updated $CONTEXT_FILE"
282
+ done
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env pwsh
2
+ # update-agent-context.ps1
3
+ #
4
+ # Refresh the managed Spec Kit section in the coding agent's context file(s)
5
+ # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
6
+ #
7
+ # Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
8
+ # agent-context extension config:
9
+ # .specify/extensions/agent-context/agent-context-config.yml
10
+ #
11
+ # Usage: update-agent-context.ps1 [plan_path]
12
+
13
+ [CmdletBinding()]
14
+ param(
15
+ [Parameter(Position = 0)]
16
+ [string]$PlanPath
17
+ )
18
+
19
+ function Get-ConfigValue {
20
+ param(
21
+ [AllowNull()][object]$Object,
22
+ [Parameter(Mandatory = $true)][string]$Key
23
+ )
24
+
25
+ if ($null -eq $Object) {
26
+ return $null
27
+ }
28
+ if ($Object -is [System.Collections.IDictionary]) {
29
+ return $Object[$Key]
30
+ }
31
+ $prop = $Object.PSObject.Properties[$Key]
32
+ if ($prop) {
33
+ return $prop.Value
34
+ }
35
+ return $null
36
+ }
37
+
38
+ function Test-ConfigObject {
39
+ param(
40
+ [AllowNull()][object]$Object
41
+ )
42
+
43
+ if ($null -eq $Object) {
44
+ return $false
45
+ }
46
+ if ($Object -is [System.Collections.IDictionary]) {
47
+ return $true
48
+ }
49
+ if ($Object -is [System.Management.Automation.PSCustomObject]) {
50
+ return $true
51
+ }
52
+ return $false
53
+ }
54
+
55
+ function Resolve-ContextPath {
56
+ param(
57
+ [Parameter(Mandatory = $true)][string]$Root,
58
+ [Parameter(Mandatory = $true)][string]$RelativePath
59
+ )
60
+
61
+ $rootFull = [System.IO.Path]::GetFullPath($Root)
62
+ $segments = $RelativePath -split '/'
63
+ $resolved = $rootFull
64
+
65
+ foreach ($segment in $segments) {
66
+ if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') {
67
+ continue
68
+ }
69
+
70
+ $candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment))
71
+ if (Test-Path -LiteralPath $candidate) {
72
+ $item = Get-Item -LiteralPath $candidate -Force
73
+ if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
74
+ $target = $item.Target
75
+ if ($target -is [System.Array]) {
76
+ $target = $target[0]
77
+ }
78
+ if ($target) {
79
+ if ([System.IO.Path]::IsPathRooted($target)) {
80
+ $candidate = [System.IO.Path]::GetFullPath($target)
81
+ } else {
82
+ $candidate = [System.IO.Path]::GetFullPath(
83
+ (Join-Path (Split-Path -Parent $candidate) $target)
84
+ )
85
+ }
86
+ }
87
+ }
88
+ }
89
+ $resolved = $candidate
90
+ }
91
+
92
+ return $resolved
93
+ }
94
+
95
+ function Test-IsSubPath {
96
+ param(
97
+ [Parameter(Mandatory = $true)][string]$Root,
98
+ [Parameter(Mandatory = $true)][string]$Path
99
+ )
100
+
101
+ $comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
102
+ [System.StringComparison]::OrdinalIgnoreCase
103
+ } else {
104
+ [System.StringComparison]::Ordinal
105
+ }
106
+ $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd(
107
+ [System.IO.Path]::DirectorySeparatorChar,
108
+ [System.IO.Path]::AltDirectorySeparatorChar
109
+ )
110
+ $pathFull = [System.IO.Path]::GetFullPath($Path)
111
+ return $pathFull.Equals($rootFull, $comparison) -or
112
+ $pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison)
113
+ }
114
+
115
+ $ErrorActionPreference = 'Stop'
116
+ $DefaultStart = '<!-- SPECKIT START -->'
117
+ $DefaultEnd = '<!-- SPECKIT END -->'
118
+ $ProjectRoot = (Get-Location).Path
119
+ $ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
120
+
121
+ if (-not (Test-Path -LiteralPath $ExtConfig)) {
122
+ Write-Warning "agent-context: $ExtConfig not found; nothing to do."
123
+ exit 0
124
+ }
125
+
126
+ $Options = $null
127
+ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
128
+ try {
129
+ $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
130
+ } catch {
131
+ # fall through to Python fallback
132
+ }
133
+ }
134
+
135
+ if ($null -eq $Options) {
136
+ # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
137
+ $pythonCmd = $null
138
+ $pythonCandidates = @()
139
+ if ($env:SPECKIT_PYTHON) {
140
+ $pythonCandidates += $env:SPECKIT_PYTHON
141
+ }
142
+ $pythonCandidates += @('python3', 'python')
143
+ foreach ($candidate in $pythonCandidates) {
144
+ if (Get-Command $candidate -ErrorAction SilentlyContinue) {
145
+ # Verify it is Python 3 with PyYAML available.
146
+ $null = & $candidate -c "import sys; import yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null
147
+ if ($LASTEXITCODE -eq 0) {
148
+ $pythonCmd = $candidate
149
+ break
150
+ }
151
+ }
152
+ }
153
+
154
+ if ($pythonCmd) {
155
+ $pyScript = $null
156
+ try {
157
+ $pyScript = [System.IO.Path]::GetTempFileName()
158
+ Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @'
159
+ import json
160
+ import sys
161
+ try:
162
+ import yaml
163
+ except ImportError:
164
+ print(
165
+ "agent-context: PyYAML is required to parse extension config; cannot update context.",
166
+ file=sys.stderr,
167
+ )
168
+ sys.exit(2)
169
+
170
+ try:
171
+ with open(sys.argv[1], "r", encoding="utf-8") as fh:
172
+ data = yaml.safe_load(fh)
173
+ except Exception as exc:
174
+ print(
175
+ f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
176
+ file=sys.stderr,
177
+ )
178
+ sys.exit(2)
179
+
180
+ if not isinstance(data, dict):
181
+ data = {}
182
+
183
+ print(json.dumps(data))
184
+ '@
185
+ $jsonOut = & $pythonCmd $pyScript $ExtConfig
186
+ if ($LASTEXITCODE -eq 0 -and $jsonOut) {
187
+ $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
188
+ }
189
+ } catch {
190
+ $Options = $null
191
+ } finally {
192
+ if ($pyScript -and (Test-Path -LiteralPath $pyScript)) {
193
+ Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue
194
+ }
195
+ }
196
+ }
197
+
198
+ if (-not $Options) {
199
+ Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
200
+ exit 0
201
+ }
202
+ }
203
+
204
+ if (-not (Test-ConfigObject -Object $Options)) {
205
+ Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
206
+ exit 0
207
+ }
208
+
209
+ $ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files'
210
+ $ContextFiles = @()
211
+ if ($null -ne $ConfiguredContextFiles) {
212
+ foreach ($item in @($ConfiguredContextFiles)) {
213
+ if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) {
214
+ $ContextFiles += $item.Trim()
215
+ }
216
+ }
217
+ }
218
+ if ($ContextFiles.Count -eq 0) {
219
+ $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
220
+ if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) {
221
+ $ContextFiles += $ContextFile.Trim()
222
+ }
223
+ }
224
+ $pathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
225
+ [System.StringComparer]::OrdinalIgnoreCase
226
+ } else {
227
+ [System.StringComparer]::Ordinal
228
+ }
229
+ $seenContextFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparison)
230
+ $dedupedContextFiles = @()
231
+ foreach ($ContextFile in $ContextFiles) {
232
+ if ($seenContextFiles.Add($ContextFile)) {
233
+ $dedupedContextFiles += $ContextFile
234
+ }
235
+ }
236
+ $ContextFiles = $dedupedContextFiles
237
+ if ($ContextFiles.Count -eq 0) {
238
+ Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.'
239
+ exit 0
240
+ }
241
+
242
+ foreach ($ContextFile in $ContextFiles) {
243
+ # Reject absolute paths, drive-qualified paths, backslash separators, and '..' path segments in context files
244
+ if ($ContextFile -match '^[A-Za-z]:') {
245
+ Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
246
+ exit 1
247
+ }
248
+ if ([System.IO.Path]::IsPathRooted($ContextFile)) {
249
+ Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
250
+ exit 1
251
+ }
252
+ if ($ContextFile.Contains('\')) {
253
+ Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'."
254
+ exit 1
255
+ }
256
+ $cfSegments = $ContextFile -split '[/\\]'
257
+ if ($cfSegments -contains '..') {
258
+ Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'."
259
+ exit 1
260
+ }
261
+ $resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile
262
+ if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) {
263
+ Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'."
264
+ exit 1
265
+ }
266
+ }
267
+
268
+ $MarkerStart = $DefaultStart
269
+ $MarkerEnd = $DefaultEnd
270
+ $cm = Get-ConfigValue -Object $Options -Key 'context_markers'
271
+ if ($cm) {
272
+ $cmStart = Get-ConfigValue -Object $cm -Key 'start'
273
+ if ($cmStart -is [string] -and $cmStart) {
274
+ $MarkerStart = $cmStart
275
+ }
276
+ $cmEnd = Get-ConfigValue -Object $cm -Key 'end'
277
+ if ($cmEnd -is [string] -and $cmEnd) {
278
+ $MarkerEnd = $cmEnd
279
+ }
280
+ }
281
+
282
+ if (-not $PlanPath) {
283
+ # Discover plan.md exactly one level deep (specs/<feature>/plan.md),
284
+ # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
285
+ # $ErrorActionPreference = 'Stop' don't abort the script.
286
+ try {
287
+ $specsDir = Join-Path $ProjectRoot 'specs'
288
+ $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
289
+ ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
290
+ Where-Object { $_ } |
291
+ Sort-Object LastWriteTime -Descending |
292
+ Select-Object -First 1
293
+ if ($candidate) {
294
+ $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
295
+ }
296
+ } catch {
297
+ # Non-fatal: continue without a plan path.
298
+ }
299
+ }
300
+
301
+ $lines = @($MarkerStart,
302
+ 'For additional context about technologies to be used, project structure,',
303
+ 'shell commands, and other important information, read the current plan')
304
+ if ($PlanPath) {
305
+ $lines += "at $PlanPath"
306
+ }
307
+ $lines += $MarkerEnd
308
+ $Section = ($lines -join "`n") + "`n"
309
+
310
+ foreach ($ContextFile in $ContextFiles) {
311
+ $CtxPath = Join-Path $ProjectRoot $ContextFile
312
+ $CtxDir = Split-Path -Parent $CtxPath
313
+ if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
314
+ New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
315
+ }
316
+
317
+ if (Test-Path -LiteralPath $CtxPath) {
318
+ $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
319
+ # Strip UTF-8 BOM if present
320
+ if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
321
+ $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
322
+ } else {
323
+ $content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
324
+ }
325
+
326
+ $s = $content.IndexOf($MarkerStart)
327
+ $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
328
+
329
+ if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
330
+ $endOfMarker = $e + $MarkerEnd.Length
331
+ if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
332
+ if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
333
+ $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
334
+ } elseif ($s -ge 0) {
335
+ $newContent = $content.Substring(0, $s) + $Section
336
+ } elseif ($e -ge 0) {
337
+ $endOfMarker = $e + $MarkerEnd.Length
338
+ if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
339
+ if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
340
+ $newContent = $Section + $content.Substring($endOfMarker)
341
+ } else {
342
+ if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
343
+ if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
344
+ }
345
+ } else {
346
+ $newContent = $Section
347
+ }
348
+
349
+ $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
350
+ [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
351
+
352
+ Write-Host "agent-context: updated $ContextFile"
353
+ }
@@ -0,0 +1,23 @@
1
+ installed:
2
+ - agent-context
3
+ settings:
4
+ auto_execute_hooks: true
5
+ hooks:
6
+ after_specify:
7
+ - extension: agent-context
8
+ command: speckit.agent-context.update
9
+ enabled: true
10
+ optional: true
11
+ priority: 10
12
+ prompt: Execute speckit.agent-context.update?
13
+ description: Refresh agent context after specification
14
+ condition: null
15
+ after_plan:
16
+ - extension: agent-context
17
+ command: speckit.agent-context.update
18
+ enabled: true
19
+ optional: true
20
+ priority: 10
21
+ prompt: Execute speckit.agent-context.update?
22
+ description: Refresh agent context after planning
23
+ condition: null
@@ -0,0 +1,3 @@
1
+ {
2
+ "feature_directory": "specs/001-cross-service-reactor-sagas"
3
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "ai": "claude",
3
+ "ai_skills": true,
4
+ "feature_numbering": "sequential",
5
+ "here": true,
6
+ "integration": "claude",
7
+ "script": "sh",
8
+ "speckit_version": "0.11.6"
9
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": "0.11.6",
3
+ "integration_state_schema": 1,
4
+ "installed_integrations": [
5
+ "claude"
6
+ ],
7
+ "integration_settings": {
8
+ "claude": {
9
+ "script": "sh",
10
+ "invoke_separator": "-"
11
+ }
12
+ },
13
+ "integration": "claude",
14
+ "default_integration": "claude"
15
+ }