tmux-agent 0.1.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 (161) hide show
  1. package/.codex/skills/speckit/SKILL.md +173 -0
  2. package/.codex/skills/speckit/assets/templates/checklist-template.md +49 -0
  3. package/.codex/skills/speckit/assets/templates/notes-entrypoints-template.md +11 -0
  4. package/.codex/skills/speckit/assets/templates/notes-questions-template.md +7 -0
  5. package/.codex/skills/speckit/assets/templates/notes-readme-template.md +36 -0
  6. package/.codex/skills/speckit/assets/templates/notes-session-template.md +21 -0
  7. package/.codex/skills/speckit/assets/templates/plan-template.md +126 -0
  8. package/.codex/skills/speckit/assets/templates/spec-template.md +135 -0
  9. package/.codex/skills/speckit/assets/templates/tasks-template.md +269 -0
  10. package/.codex/skills/speckit/references/acceptance.md +183 -0
  11. package/.codex/skills/speckit/references/analyze.md +186 -0
  12. package/.codex/skills/speckit/references/checklist.md +302 -0
  13. package/.codex/skills/speckit/references/clarify-auto.md +69 -0
  14. package/.codex/skills/speckit/references/clarify-detailed.md +78 -0
  15. package/.codex/skills/speckit/references/clarify.md +189 -0
  16. package/.codex/skills/speckit/references/constitution.md +90 -0
  17. package/.codex/skills/speckit/references/group.md +89 -0
  18. package/.codex/skills/speckit/references/implement-task.md +115 -0
  19. package/.codex/skills/speckit/references/implement.md +129 -0
  20. package/.codex/skills/speckit/references/notes.md +82 -0
  21. package/.codex/skills/speckit/references/plan-deep.md +87 -0
  22. package/.codex/skills/speckit/references/plan-from-questions.md +115 -0
  23. package/.codex/skills/speckit/references/plan-from-review.md +89 -0
  24. package/.codex/skills/speckit/references/plan.md +97 -0
  25. package/.codex/skills/speckit/references/review-plan.md +156 -0
  26. package/.codex/skills/speckit/references/specify.md +246 -0
  27. package/.codex/skills/speckit/references/tasks.md +155 -0
  28. package/.codex/skills/speckit/references/taskstoissues.md +33 -0
  29. package/.codex/skills/speckit/scripts/bash/check-prerequisites.sh +206 -0
  30. package/.codex/skills/speckit/scripts/bash/common.sh +191 -0
  31. package/.codex/skills/speckit/scripts/bash/create-new-feature.sh +259 -0
  32. package/.codex/skills/speckit/scripts/bash/extract-coded-points.sh +322 -0
  33. package/.codex/skills/speckit/scripts/bash/extract-spec-ids.sh +238 -0
  34. package/.codex/skills/speckit/scripts/bash/extract-tasks.sh +295 -0
  35. package/.codex/skills/speckit/scripts/bash/extract-user-stories.sh +312 -0
  36. package/.codex/skills/speckit/scripts/bash/setup-notes.sh +182 -0
  37. package/.codex/skills/speckit/scripts/bash/setup-plan.sh +110 -0
  38. package/.codex/skills/speckit/scripts/bash/show-todo-tasks.sh +257 -0
  39. package/.codex/skills/speckit/scripts/bash/spec-group-checklist.sh +402 -0
  40. package/.codex/skills/speckit/scripts/bash/spec-group-members.sh +215 -0
  41. package/.codex/skills/speckit/scripts/bash/spec-registry-graph.sh +399 -0
  42. package/.specify/memory/constitution.md +67 -0
  43. package/.specify/templates/agent-file-template.md +28 -0
  44. package/.specify/templates/checklist-template.md +49 -0
  45. package/.specify/templates/plan-template.md +126 -0
  46. package/.specify/templates/spec-template.md +135 -0
  47. package/.specify/templates/tasks-template.md +269 -0
  48. package/README.md +128 -0
  49. package/README.zh-CN.md +127 -0
  50. package/bun.lock +269 -0
  51. package/dist/cli/commands/codex/forkHome.js +88 -0
  52. package/dist/cli/commands/codex/send.js +55 -0
  53. package/dist/cli/commands/codex/sessionInfo.js +42 -0
  54. package/dist/cli/commands/codex/spawn.js +68 -0
  55. package/dist/cli/commands/find.js +26 -0
  56. package/dist/cli/commands/paneKill.js +33 -0
  57. package/dist/cli/commands/paneSpawn.js +40 -0
  58. package/dist/cli/commands/paneTitle.js +33 -0
  59. package/dist/cli/commands/read.js +34 -0
  60. package/dist/cli/commands/send.js +51 -0
  61. package/dist/cli/commands/snapshot.js +19 -0
  62. package/dist/cli/commands/ui/select.js +41 -0
  63. package/dist/cli/commands/windowKill.js +25 -0
  64. package/dist/cli/commands/windowLs.js +15 -0
  65. package/dist/cli/commands/windowNew.js +28 -0
  66. package/dist/cli/commands/windowRename.js +25 -0
  67. package/dist/cli/index.js +365 -0
  68. package/dist/cli/parse.js +39 -0
  69. package/dist/lib/codex/forkHome.js +101 -0
  70. package/dist/lib/codex/isCodexPane.js +55 -0
  71. package/dist/lib/codex/send.js +58 -0
  72. package/dist/lib/codex/sessionInfo.js +449 -0
  73. package/dist/lib/codex/spawn.js +246 -0
  74. package/dist/lib/contracts/types.js +2 -0
  75. package/dist/lib/fs/safeRm.js +32 -0
  76. package/dist/lib/io/readStdin.js +14 -0
  77. package/dist/lib/os/process.js +55 -0
  78. package/dist/lib/output/format.js +95 -0
  79. package/dist/lib/proc/lsof.js +42 -0
  80. package/dist/lib/proc/ps.js +60 -0
  81. package/dist/lib/targeting/errors.js +13 -0
  82. package/dist/lib/targeting/resolvePaneTarget.js +91 -0
  83. package/dist/lib/targeting/resolveWindowTarget.js +40 -0
  84. package/dist/lib/targeting/scope.js +58 -0
  85. package/dist/lib/tmux/capturePane.js +20 -0
  86. package/dist/lib/tmux/exec.js +66 -0
  87. package/dist/lib/tmux/paneOps.js +29 -0
  88. package/dist/lib/tmux/paste.js +23 -0
  89. package/dist/lib/tmux/sendKeys.js +47 -0
  90. package/dist/lib/tmux/session.js +29 -0
  91. package/dist/lib/tmux/snapshotPanes.js +46 -0
  92. package/dist/lib/tmux/snapshotWindows.js +24 -0
  93. package/dist/lib/tmux/windowOps.js +32 -0
  94. package/dist/lib/ui/popupSelect.js +432 -0
  95. package/dist/lib/ui/popupSupport.js +76 -0
  96. package/package.json +23 -0
  97. package/src/cli/commands/codex/forkHome.ts +141 -0
  98. package/src/cli/commands/codex/send.ts +83 -0
  99. package/src/cli/commands/codex/sessionInfo.ts +59 -0
  100. package/src/cli/commands/codex/spawn.ts +90 -0
  101. package/src/cli/commands/find.ts +40 -0
  102. package/src/cli/commands/paneKill.ts +49 -0
  103. package/src/cli/commands/paneSpawn.ts +53 -0
  104. package/src/cli/commands/paneTitle.ts +50 -0
  105. package/src/cli/commands/read.ts +48 -0
  106. package/src/cli/commands/send.ts +71 -0
  107. package/src/cli/commands/snapshot.ts +28 -0
  108. package/src/cli/commands/ui/select.ts +49 -0
  109. package/src/cli/commands/windowKill.ts +35 -0
  110. package/src/cli/commands/windowLs.ts +20 -0
  111. package/src/cli/commands/windowNew.ts +40 -0
  112. package/src/cli/commands/windowRename.ts +36 -0
  113. package/src/cli/index.ts +430 -0
  114. package/src/lib/codex/forkHome.ts +148 -0
  115. package/src/lib/codex/isCodexPane.ts +56 -0
  116. package/src/lib/codex/send.ts +84 -0
  117. package/src/lib/codex/sessionInfo.ts +521 -0
  118. package/src/lib/codex/spawn.ts +305 -0
  119. package/src/lib/contracts/types.ts +30 -0
  120. package/src/lib/fs/safeRm.ts +32 -0
  121. package/src/lib/io/readStdin.ts +11 -0
  122. package/src/lib/output/format.ts +105 -0
  123. package/src/lib/proc/lsof.ts +44 -0
  124. package/src/lib/proc/ps.ts +70 -0
  125. package/src/lib/targeting/errors.ts +25 -0
  126. package/src/lib/targeting/resolvePaneTarget.ts +106 -0
  127. package/src/lib/targeting/resolveWindowTarget.ts +45 -0
  128. package/src/lib/targeting/scope.ts +76 -0
  129. package/src/lib/tmux/capturePane.ts +21 -0
  130. package/src/lib/tmux/exec.ts +90 -0
  131. package/src/lib/tmux/paneOps.ts +35 -0
  132. package/src/lib/tmux/paste.ts +20 -0
  133. package/src/lib/tmux/sendKeys.ts +72 -0
  134. package/src/lib/tmux/session.ts +27 -0
  135. package/src/lib/tmux/snapshotPanes.ts +52 -0
  136. package/src/lib/tmux/snapshotWindows.ts +23 -0
  137. package/src/lib/tmux/windowOps.ts +43 -0
  138. package/src/lib/ui/popupSelect.ts +561 -0
  139. package/src/lib/ui/popupSupport.ts +84 -0
  140. package/tests/e2e/codexForkHome.test.ts +146 -0
  141. package/tests/e2e/codexSessionInfo.test.ts +112 -0
  142. package/tests/e2e/codexTuiSend.test.ts +68 -0
  143. package/tests/integration/codexSpawn.test.ts +113 -0
  144. package/tests/integration/paneOps.test.ts +60 -0
  145. package/tests/integration/sendRead.test.ts +52 -0
  146. package/tests/integration/snapshot.test.ts +39 -0
  147. package/tests/integration/tmuxHarness.ts +39 -0
  148. package/tests/integration/windowOps.test.ts +60 -0
  149. package/tests/unit/codexSend.test.ts +105 -0
  150. package/tests/unit/codexSessionInfo.test.ts +88 -0
  151. package/tests/unit/codexSpawn.test.ts +34 -0
  152. package/tests/unit/keys.test.ts +30 -0
  153. package/tests/unit/outputFormat.test.ts +52 -0
  154. package/tests/unit/popupSelect.test.ts +77 -0
  155. package/tests/unit/popupSupport.test.ts +109 -0
  156. package/tests/unit/resolvePaneTarget.test.ts +43 -0
  157. package/tests/unit/resolveWindowTarget.test.ts +36 -0
  158. package/tests/unit/safeRm.test.ts +41 -0
  159. package/tests/unit/scope.test.ts +57 -0
  160. package/tsconfig.json +14 -0
  161. package/vitest.config.ts +16 -0
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Extract coded points (FR/NFR/SC) from one or more spec.md files.
4
+ #
5
+ # This script is intentionally read-only and deterministic: it emits an inventory
6
+ # of codes with file+line evidence so acceptance review does not rely on ad-hoc grep.
7
+ #
8
+ # Usage:
9
+ # ./extract-coded-points.sh [--json] [--feature <id>]... [<id>...]
10
+ #
11
+ # Examples:
12
+ # ./extract-coded-points.sh --json --feature 024 --feature 025
13
+ # ./extract-coded-points.sh 024 025
14
+ #
15
+ # Notes:
16
+ # - <id> can be "024" (numeric prefix) or "024-some-feature" (full directory).
17
+ # - Feature resolution reuses check-prerequisites.sh to find absolute paths.
18
+
19
+ set -euo pipefail
20
+
21
+ JSON_MODE=false
22
+ FEATURES=()
23
+
24
+ while [[ $# -gt 0 ]]; do
25
+ case "$1" in
26
+ --json)
27
+ JSON_MODE=true
28
+ shift
29
+ ;;
30
+ --feature)
31
+ if [[ $# -lt 2 || "${2:-}" == --* ]]; then
32
+ echo "ERROR: --feature requires a value." >&2
33
+ exit 1
34
+ fi
35
+ FEATURES+=("$2")
36
+ shift 2
37
+ ;;
38
+ --feature=*)
39
+ FEATURES+=("${1#--feature=}")
40
+ shift
41
+ ;;
42
+ --help|-h)
43
+ cat << 'EOF'
44
+ Usage: extract-coded-points.sh [--json] [--feature <id>]... [<id>...]
45
+
46
+ Extract FR/NFR/SC coded points from one or more spec files and emit an inventory
47
+ with file+line evidence (useful for speckit.acceptance).
48
+
49
+ Options:
50
+ --json Output JSON (default: text)
51
+ --feature <id> Target a specific spec (repeatable)
52
+ --help, -h Show this help message
53
+
54
+ Examples:
55
+ ./extract-coded-points.sh --json --feature 024 --feature 025
56
+ ./extract-coded-points.sh 024 025
57
+ EOF
58
+ exit 0
59
+ ;;
60
+ --*)
61
+ echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
62
+ exit 1
63
+ ;;
64
+ *)
65
+ if [[ "$1" =~ ^[0-9]{3}(-.+)?$ ]]; then
66
+ FEATURES+=("$1")
67
+ shift
68
+ else
69
+ echo "ERROR: Unknown argument '$1'. Expected feature id like 024 or 024-some-feature." >&2
70
+ exit 1
71
+ fi
72
+ ;;
73
+ esac
74
+ done
75
+
76
+ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
77
+ CHECK_PREREQ="$SCRIPT_DIR/check-prerequisites.sh"
78
+
79
+ python3 - "$CHECK_PREREQ" "$JSON_MODE" "${FEATURES[@]}" << 'PY'
80
+ from __future__ import annotations
81
+
82
+ import json
83
+ import re
84
+ import subprocess
85
+ import sys
86
+ from pathlib import Path
87
+ from typing import Any
88
+
89
+
90
+ CHECK = sys.argv[1]
91
+ JSON_MODE = sys.argv[2].lower() == "true"
92
+ INPUT_FEATURES = sys.argv[3:]
93
+
94
+
95
+ CODE_PATTERN = re.compile(r"\b(?:FR|NFR|SC)-\d{3}[A-Za-z]?\b")
96
+ HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
97
+
98
+
99
+ def run_check(feature: str | None) -> dict[str, Any]:
100
+ cmd = [CHECK, "--json", "--paths-only"]
101
+ if feature:
102
+ cmd += ["--feature", feature]
103
+ out = subprocess.check_output(cmd, text=True)
104
+ return json.loads(out)
105
+
106
+
107
+ def is_definition_line(line: str, match_start: int, match_end: int) -> bool:
108
+ prefix = line[:match_start]
109
+ suffix = line[match_end:]
110
+
111
+ prefix_stripped = prefix.lstrip()
112
+
113
+ # Remove common markdown list markers.
114
+ if prefix_stripped.startswith(("-", "*")):
115
+ prefix_stripped = prefix_stripped[1:]
116
+ else:
117
+ m = re.match(r"^\d+\.\s+", prefix_stripped)
118
+ if m:
119
+ prefix_stripped = prefix_stripped[m.end() :]
120
+
121
+ # Allow surrounding markdown emphasis.
122
+ prefix_stripped = prefix_stripped.strip().strip("*").strip()
123
+ if prefix_stripped:
124
+ return False
125
+
126
+ suffix_stripped = suffix.lstrip()
127
+ if suffix_stripped.startswith("**"):
128
+ suffix_stripped = suffix_stripped[2:].lstrip()
129
+
130
+ return suffix_stripped.startswith((":",":"))
131
+
132
+
133
+ def extract_occurrences(spec_path: Path) -> list[dict[str, Any]]:
134
+ if not spec_path.exists():
135
+ return []
136
+
137
+ lines = spec_path.read_text(encoding="utf-8", errors="replace").splitlines()
138
+ last_heading: str | None = None
139
+ occurrences: list[dict[str, Any]] = []
140
+
141
+ for line_no, line in enumerate(lines, start=1):
142
+ heading_match = HEADING_PATTERN.match(line)
143
+ if heading_match:
144
+ last_heading = heading_match.group(2).strip() or None
145
+
146
+ for match in CODE_PATTERN.finditer(line):
147
+ code = match.group(0)
148
+ code_type = code.split("-", 1)[0]
149
+ kind = "definition" if is_definition_line(line, match.start(), match.end()) else "reference"
150
+ occurrences.append(
151
+ {
152
+ "code": code,
153
+ "type": code_type,
154
+ "line": line_no,
155
+ "col": match.start() + 1,
156
+ "heading": last_heading,
157
+ "kind": kind,
158
+ "raw": line.rstrip("\n"),
159
+ }
160
+ )
161
+
162
+ return occurrences
163
+
164
+
165
+ def build_points(occurrences: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
166
+ by_code: dict[str, dict[str, Any]] = {}
167
+ for occ in occurrences:
168
+ entry = by_code.setdefault(
169
+ occ["code"],
170
+ {"code": occ["code"], "type": occ["type"], "definitions": [], "references": []},
171
+ )
172
+ if occ["kind"] == "definition":
173
+ entry["definitions"].append(occ)
174
+ else:
175
+ entry["references"].append(occ)
176
+
177
+ points: list[dict[str, Any]] = []
178
+ duplicate_definitions: list[dict[str, Any]] = []
179
+ orphan_references: list[dict[str, Any]] = []
180
+
181
+ for code, entry in by_code.items():
182
+ defs = sorted(entry["definitions"], key=lambda x: (x["line"], x["col"]))
183
+ refs = sorted(entry["references"], key=lambda x: (x["line"], x["col"]))
184
+ if not defs:
185
+ orphan_references.append(
186
+ {
187
+ "code": code,
188
+ "type": entry["type"],
189
+ "references": [{"line": r["line"], "col": r["col"], "raw": r["raw"]} for r in refs],
190
+ }
191
+ )
192
+ continue
193
+
194
+ definition = defs[0]
195
+ points.append(
196
+ {
197
+ "code": code,
198
+ "type": entry["type"],
199
+ "definition": {
200
+ "line": definition["line"],
201
+ "col": definition["col"],
202
+ "heading": definition["heading"],
203
+ "raw": definition["raw"],
204
+ },
205
+ "references": [{"line": r["line"], "col": r["col"], "raw": r["raw"]} for r in refs],
206
+ }
207
+ )
208
+
209
+ if len(defs) > 1:
210
+ duplicate_definitions.append(
211
+ {
212
+ "code": code,
213
+ "type": entry["type"],
214
+ "definitions": [
215
+ {"line": d["line"], "col": d["col"], "raw": d["raw"]} for d in defs
216
+ ],
217
+ }
218
+ )
219
+
220
+ points = sorted(points, key=lambda x: (x["type"], x["code"]))
221
+ duplicate_definitions = sorted(duplicate_definitions, key=lambda x: x["code"])
222
+ orphan_references = sorted(orphan_references, key=lambda x: x["code"])
223
+
224
+ return points, duplicate_definitions, orphan_references
225
+
226
+
227
+ def main() -> int:
228
+ features = list(INPUT_FEATURES)
229
+ if not features:
230
+ # Fall back to inferred current feature (may be "latest"); caller should usually pass ids.
231
+ features = [None] # type: ignore[list-item]
232
+
233
+ targets: list[dict[str, Any]] = []
234
+
235
+ for feature in features:
236
+ try:
237
+ paths = run_check(feature) # includes REPO_ROOT/BRANCH/FEATURE_DIR/FEATURE_SPEC...
238
+ except subprocess.CalledProcessError as e:
239
+ targets.append(
240
+ {
241
+ "input": feature,
242
+ "error": {
243
+ "message": "check-prerequisites failed",
244
+ "exitCode": e.returncode,
245
+ "stdout": (e.stdout or "").strip(),
246
+ "stderr": (e.stderr or "").strip(),
247
+ },
248
+ }
249
+ )
250
+ continue
251
+
252
+ spec_path = Path(paths.get("FEATURE_SPEC", ""))
253
+ occurrences = extract_occurrences(spec_path)
254
+ points, duplicate_definitions, orphan_references = build_points(occurrences)
255
+
256
+ by_type_defs: dict[str, int] = {}
257
+ for item in points:
258
+ by_type_defs[item["type"]] = by_type_defs.get(item["type"], 0) + 1
259
+
260
+ targets.append(
261
+ {
262
+ "input": feature,
263
+ "feature": paths.get("BRANCH"),
264
+ "repoRoot": paths.get("REPO_ROOT"),
265
+ "featureDir": paths.get("FEATURE_DIR"),
266
+ "specFile": str(spec_path),
267
+ "planFile": paths.get("IMPL_PLAN"),
268
+ "tasksFile": paths.get("TASKS"),
269
+ "counts": {
270
+ "definitions": len(points),
271
+ "byTypeDefinitions": dict(sorted(by_type_defs.items(), key=lambda kv: kv[0])),
272
+ "occurrences": len(occurrences),
273
+ "duplicateDefinitions": len(duplicate_definitions),
274
+ "orphanReferences": len(orphan_references),
275
+ },
276
+ "duplicateDefinitions": duplicate_definitions,
277
+ "orphanReferences": orphan_references,
278
+ "points": points,
279
+ "occurrences": occurrences,
280
+ }
281
+ )
282
+
283
+ if JSON_MODE:
284
+ payload = {"targets": targets}
285
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
286
+ return 0
287
+
288
+ # Text output (compact)
289
+ for t in targets:
290
+ if "error" in t:
291
+ print(f"[ERROR] {t.get('input')}: {t['error']['message']}")
292
+ continue
293
+ print(f"{t.get('feature')} ({t.get('specFile')})")
294
+ counts = t.get("counts", {})
295
+ by_type = counts.get("byTypeDefinitions", {})
296
+ print(
297
+ " "
298
+ + " ".join(
299
+ [
300
+ f"definitions={counts.get('definitions')}",
301
+ f"byType={by_type}",
302
+ f"occurrences={counts.get('occurrences')}",
303
+ f"dupDefs={counts.get('duplicateDefinitions')}",
304
+ f"orphanRefs={counts.get('orphanReferences')}",
305
+ ]
306
+ )
307
+ )
308
+ for p in t.get("points", []):
309
+ d = p["definition"]
310
+ heading = d.get("heading")
311
+ heading_part = f" [{heading}]" if heading else ""
312
+ refs = p.get("references", [])
313
+ refs_part = f" refs={len(refs)}" if refs else ""
314
+ print(f" - {p['code']}:{d['line']}:{d['col']}{heading_part}{refs_part} {d['raw']}")
315
+ print()
316
+
317
+ return 0
318
+
319
+
320
+ if __name__ == "__main__":
321
+ raise SystemExit(main())
322
+ PY
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Extract spec numbering inventories (US / FR/NFR/SC / Tasks) for one or more specs.
4
+ #
5
+ # This is a convenience aggregator that delegates to:
6
+ # - extract-user-stories.sh
7
+ # - extract-coded-points.sh
8
+ # - extract-tasks.sh
9
+ #
10
+ # Usage:
11
+ # ./extract-spec-ids.sh [--json] [--kind <kind>]... [--feature <id>]... [<id>...]
12
+ #
13
+ # Kinds:
14
+ # us User Stories (from spec.md headings)
15
+ # codes FR/NFR/SC coded points (from spec.md)
16
+ # tasks Tasks (from tasks.md)
17
+ #
18
+ # Notes:
19
+ # - If no --kind is provided, defaults to: us,codes,tasks
20
+ # - Read-only. Exit code is non-zero if any selected sub-command returns non-zero.
21
+
22
+ set -euo pipefail
23
+
24
+ JSON_MODE=false
25
+ SHOW_HELP=false
26
+ KINDS_RAW=()
27
+ FORWARD_ARGS=()
28
+
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --json)
32
+ JSON_MODE=true
33
+ shift
34
+ ;;
35
+ --kind)
36
+ if [[ $# -lt 2 || "${2:-}" == --* ]]; then
37
+ echo "ERROR: --kind requires a value (us|codes|tasks|all)." >&2
38
+ exit 1
39
+ fi
40
+ KINDS_RAW+=("$2")
41
+ shift 2
42
+ ;;
43
+ --kind=*)
44
+ KINDS_RAW+=("${1#--kind=}")
45
+ shift
46
+ ;;
47
+ --kinds)
48
+ if [[ $# -lt 2 || "${2:-}" == --* ]]; then
49
+ echo "ERROR: --kinds requires a value (comma-separated list)." >&2
50
+ exit 1
51
+ fi
52
+ KINDS_RAW+=("$2")
53
+ shift 2
54
+ ;;
55
+ --kinds=*)
56
+ KINDS_RAW+=("${1#--kinds=}")
57
+ shift
58
+ ;;
59
+ --help|-h)
60
+ SHOW_HELP=true
61
+ shift
62
+ ;;
63
+ *)
64
+ FORWARD_ARGS+=("$1")
65
+ shift
66
+ ;;
67
+ esac
68
+ done
69
+
70
+ if $SHOW_HELP; then
71
+ cat << 'EOF'
72
+ Usage: extract-spec-ids.sh [--json] [--kind <kind>]... [--feature <id>]... [<id>...]
73
+
74
+ Extract spec numbering inventories (US / FR/NFR/SC / Tasks) for one or more specs.
75
+
76
+ Kinds:
77
+ us User Stories (from spec.md headings)
78
+ codes FR/NFR/SC coded points (from spec.md)
79
+ tasks Tasks (from tasks.md)
80
+ all Same as: us,codes,tasks
81
+
82
+ Options:
83
+ --json Output JSON (default: text)
84
+ --kind <kind> Select a kind (repeatable)
85
+ --kinds <list> Select kinds (comma-separated)
86
+ --feature <id> Target a specific spec (repeatable)
87
+ --help, -h Show this help message
88
+
89
+ Examples:
90
+ ./extract-spec-ids.sh --feature 061
91
+ ./extract-spec-ids.sh --kind us --kind codes 061
92
+ ./extract-spec-ids.sh --kinds us,tasks --feature 060 --feature 061
93
+ ./extract-spec-ids.sh --json --kinds us,codes --feature 061
94
+ EOF
95
+ exit 0
96
+ fi
97
+
98
+ normalize_kind() {
99
+ local k="$1"
100
+ k="$(printf '%s' "$k" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
101
+ printf '%s' "$k"
102
+ }
103
+
104
+ declare -A kind_set=()
105
+ KINDS=()
106
+
107
+ if [[ ${#KINDS_RAW[@]} -eq 0 ]]; then
108
+ KINDS_RAW=("all")
109
+ fi
110
+
111
+ for raw in "${KINDS_RAW[@]}"; do
112
+ raw="$(normalize_kind "$raw")"
113
+ IFS=',' read -r -a parts <<< "$raw"
114
+ for part in "${parts[@]}"; do
115
+ k="$(normalize_kind "$part")"
116
+ [[ -z "$k" ]] && continue
117
+
118
+ if [[ "$k" == "all" ]]; then
119
+ for kk in us codes tasks; do
120
+ if [[ -z "${kind_set[$kk]:-}" ]]; then
121
+ kind_set[$kk]=1
122
+ KINDS+=("$kk")
123
+ fi
124
+ done
125
+ continue
126
+ fi
127
+
128
+ case "$k" in
129
+ us|codes|tasks)
130
+ if [[ -z "${kind_set[$k]:-}" ]]; then
131
+ kind_set[$k]=1
132
+ KINDS+=("$k")
133
+ fi
134
+ ;;
135
+ *)
136
+ echo "ERROR: Unknown kind '$k'. Allowed: us,codes,tasks,all." >&2
137
+ exit 1
138
+ ;;
139
+ esac
140
+ done
141
+ done
142
+
143
+ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
144
+ US_SCRIPT="$SCRIPT_DIR/extract-user-stories.sh"
145
+ CODES_SCRIPT="$SCRIPT_DIR/extract-coded-points.sh"
146
+ TASKS_SCRIPT="$SCRIPT_DIR/extract-tasks.sh"
147
+
148
+ overall_status=0
149
+
150
+ if ! $JSON_MODE; then
151
+ if [[ ${#KINDS[@]} -gt 1 ]]; then
152
+ for k in "${KINDS[@]}"; do
153
+ echo "== $k =="
154
+ set +e
155
+ case "$k" in
156
+ us) "$US_SCRIPT" "${FORWARD_ARGS[@]}" ;;
157
+ codes) "$CODES_SCRIPT" "${FORWARD_ARGS[@]}" ;;
158
+ tasks) "$TASKS_SCRIPT" "${FORWARD_ARGS[@]}" ;;
159
+ esac
160
+ st=$?
161
+ set -e
162
+ if [[ $st -ne 0 ]]; then overall_status=1; fi
163
+ echo
164
+ done
165
+ else
166
+ set +e
167
+ case "${KINDS[0]}" in
168
+ us) "$US_SCRIPT" "${FORWARD_ARGS[@]}" ;;
169
+ codes) "$CODES_SCRIPT" "${FORWARD_ARGS[@]}" ;;
170
+ tasks) "$TASKS_SCRIPT" "${FORWARD_ARGS[@]}" ;;
171
+ esac
172
+ st=$?
173
+ set -e
174
+ if [[ $st -ne 0 ]]; then overall_status=1; fi
175
+ fi
176
+
177
+ exit $overall_status
178
+ fi
179
+
180
+ TMP_FILES=()
181
+ trap 'rm -f "${TMP_FILES[@]}"' EXIT
182
+
183
+ python_args=("$JSON_MODE")
184
+
185
+ for k in "${KINDS[@]}"; do
186
+ tmp="$(mktemp -t speckit-extract-spec-ids.${k}.XXXXXX)"
187
+ TMP_FILES+=("$tmp")
188
+
189
+ set +e
190
+ case "$k" in
191
+ us) "$US_SCRIPT" --json "${FORWARD_ARGS[@]}" > "$tmp" ;;
192
+ codes) "$CODES_SCRIPT" --json "${FORWARD_ARGS[@]}" > "$tmp" ;;
193
+ tasks) "$TASKS_SCRIPT" --json "${FORWARD_ARGS[@]}" > "$tmp" ;;
194
+ esac
195
+ st=$?
196
+ set -e
197
+
198
+ if [[ $st -ne 0 ]]; then overall_status=1; fi
199
+ python_args+=("$k" "$tmp" "$st")
200
+ done
201
+
202
+ python3 - "${python_args[@]}" << 'PY'
203
+ from __future__ import annotations
204
+
205
+ import json
206
+ import sys
207
+ from pathlib import Path
208
+ from typing import Any
209
+
210
+
211
+ JSON_MODE = sys.argv[1].lower() == "true"
212
+ triples = sys.argv[2:]
213
+
214
+ if len(triples) % 3 != 0:
215
+ raise SystemExit("ERROR: invalid aggregator arguments")
216
+
217
+ results: dict[str, Any] = {}
218
+ kinds: list[str] = []
219
+
220
+ for i in range(0, len(triples), 3):
221
+ kind = triples[i]
222
+ path = Path(triples[i + 1])
223
+ exit_code = int(triples[i + 2])
224
+
225
+ raw = path.read_text(encoding="utf-8", errors="replace")
226
+ try:
227
+ payload = json.loads(raw) if raw.strip() else None
228
+ except json.JSONDecodeError as e:
229
+ payload = {"error": {"message": "invalid json from subcommand", "detail": str(e)}, "raw": raw}
230
+
231
+ kinds.append(kind)
232
+ results[kind] = {"exitCode": exit_code, "payload": payload}
233
+
234
+ print(json.dumps({"kinds": kinds, "results": results}, ensure_ascii=False, indent=2))
235
+ PY
236
+
237
+ exit $overall_status
238
+