ruby_reactor 0.5.3 → 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.
- checksums.yaml +4 -4
- data/.claude/skills/speckit-agent-context-update/SKILL.md +32 -0
- data/.claude/skills/speckit-analyze/SKILL.md +262 -0
- data/.claude/skills/speckit-checklist/SKILL.md +374 -0
- data/.claude/skills/speckit-clarify/SKILL.md +286 -0
- data/.claude/skills/speckit-constitution/SKILL.md +157 -0
- data/.claude/skills/speckit-converge/SKILL.md +277 -0
- data/.claude/skills/speckit-implement/SKILL.md +224 -0
- data/.claude/skills/speckit-plan/SKILL.md +171 -0
- data/.claude/skills/speckit-specify/SKILL.md +346 -0
- data/.claude/skills/speckit-tasks/SKILL.md +215 -0
- data/.claude/skills/speckit-taskstoissues/SKILL.md +110 -0
- data/.release-please-manifest.json +1 -1
- data/.specify/extensions/.registry +19 -0
- data/.specify/extensions/agent-context/README.md +66 -0
- data/.specify/extensions/agent-context/agent-context-config.yml +5 -0
- data/.specify/extensions/agent-context/commands/speckit.agent-context.update.md +27 -0
- data/.specify/extensions/agent-context/extension.yml +34 -0
- data/.specify/extensions/agent-context/scripts/bash/update-agent-context.sh +282 -0
- data/.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +353 -0
- data/.specify/extensions.yml +23 -0
- data/.specify/feature.json +3 -0
- data/.specify/init-options.json +9 -0
- data/.specify/integration.json +15 -0
- data/.specify/integrations/claude.manifest.json +17 -0
- data/.specify/integrations/speckit.manifest.json +17 -0
- data/.specify/memory/constitution.md +134 -0
- data/.specify/scripts/bash/check-prerequisites.sh +189 -0
- data/.specify/scripts/bash/common.sh +619 -0
- data/.specify/scripts/bash/create-new-feature.sh +299 -0
- data/.specify/scripts/bash/setup-plan.sh +84 -0
- data/.specify/scripts/bash/setup-tasks.sh +91 -0
- data/.specify/templates/checklist-template.md +40 -0
- data/.specify/templates/constitution-template.md +50 -0
- data/.specify/templates/plan-template.md +113 -0
- data/.specify/templates/spec-template.md +131 -0
- data/.specify/templates/tasks-template.md +252 -0
- data/.specify/workflows/speckit/workflow.yml +77 -0
- data/.specify/workflows/workflow-registry.json +13 -0
- data/CHANGELOG.md +14 -0
- data/README.md +146 -65
- data/lib/ruby_reactor/adapters/active_job/compat.rb +24 -0
- data/lib/ruby_reactor/adapters/active_job/map_collector_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/map_element_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/active_job/router.rb +91 -0
- data/lib/ruby_reactor/adapters/active_job/sweeper_worker.rb +16 -0
- data/lib/ruby_reactor/adapters/active_job/worker.rb +24 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_collector_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/map_element_worker.rb +15 -0
- data/lib/ruby_reactor/adapters/sidekiq/router.rb +91 -0
- data/lib/ruby_reactor/adapters/sidekiq/sweeper_worker.rb +19 -0
- data/lib/ruby_reactor/adapters/sidekiq/worker.rb +25 -0
- data/lib/ruby_reactor/configuration.rb +24 -4
- data/lib/ruby_reactor/dsl/template_helpers.rb +7 -1
- data/lib/ruby_reactor/map/element_executor.rb +1 -1
- data/lib/ruby_reactor/rspec/active_job_helpers.rb +52 -0
- data/lib/ruby_reactor/rspec/async_test_helpers.rb +41 -0
- data/lib/ruby_reactor/rspec/sidekiq_helpers.rb +3 -3
- data/lib/ruby_reactor/rspec/test_subject.rb +11 -7
- data/lib/ruby_reactor/rspec.rb +4 -0
- data/lib/ruby_reactor/step.rb +4 -0
- data/lib/ruby_reactor/sweeper_job.rb +70 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/worker.rb +226 -0
- data/lib/ruby_reactor.rb +40 -1
- data/specs/active_job.md +259 -0
- metadata +54 -6
- data/lib/ruby_reactor/sidekiq_adapter.rb +0 -87
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +0 -13
- data/lib/ruby_reactor/sidekiq_workers/sweeper_worker.rb +0 -73
- 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,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
|
+
}
|