cadence-skill-installer 0.2.13 → 0.2.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadence-skill-installer",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Install the Cadence skill into supported AI tool skill directories.",
5
5
  "repository": "https://github.com/snowdamiz/cadence",
6
6
  "private": false,
package/skill/SKILL.md CHANGED
@@ -44,7 +44,7 @@ description: Structured project operating system for end-to-end greenfield or br
44
44
 
45
45
  ## Prerequisite Gate (Conditional)
46
46
  1. Invoke `skills/prerequisite-gate/SKILL.md` only when `route.skill_name` is `prerequisite-gate`.
47
- 2. If `route.skill_name` is not `prerequisite-gate` (for example `ideator`), skip prerequisite gate and follow the active route instead.
47
+ 2. If `route.skill_name` is not `prerequisite-gate` (for example `ideator` or `researcher`), skip prerequisite gate and follow the active route instead.
48
48
 
49
49
  ## Progress / Resume Flow
50
50
  1. Invoke `skills/project-progress/SKILL.md` when the user asks to continue/resume or requests progress status (for example: "continue the project", "how far along are we?", "where did we leave off?").
@@ -63,6 +63,12 @@ description: Structured project operating system for end-to-end greenfield or br
63
63
  4. If the user asks to define the project or provides a brief while route is `ideator`, invoke `skills/ideator/SKILL.md`.
64
64
  5. If route is `ideator` and the user has not provided ideation input yet, prompt with the same handoff line and wait.
65
65
 
66
+ ## Research Flow
67
+ 1. If the workflow route is `researcher`, invoke `skills/researcher/SKILL.md`.
68
+ 2. Enforce one research pass per conversation so context stays bounded.
69
+ 3. When the researcher flow reports additional passes remain, end with the exact handoff line: `Start a new chat and say "continue research".`
70
+ 4. Continue routing to researcher on subsequent chats until workflow reports the research task complete.
71
+
66
72
  ## Ideation Update Flow
67
73
  1. If the user wants to modify or discuss existing ideation, invoke `skills/ideation-updater/SKILL.md`.
68
74
  2. Use this update flow only for existing project ideation changes; use the new-chat handoff in Ideation Flow for net-new ideation discovery.
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Cadence"
3
3
  short_description: "Lifecycle + delivery system for structured project execution"
4
- default_prompt: "Use Cadence to guide this project from lifecycle setup through phased execution, traceability, audit, and milestone completion. Always read and apply the active SOUL persona from .cadence/SOUL.json (fallback: SOUL.json). Keep user-facing responses concise and outcome-focused, and never expose internal skill-routing or command-execution traces unless the user explicitly asks. At the start of each Cadence turn, run scripts/check-project-repo-status.py, run scaffold only when .cadence is missing, then run scripts/read-workflow-state.py and treat route.skill_name as authoritative for the next state-changing skill. Invoke skills/prerequisite-gate/SKILL.md only when route.skill_name is prerequisite-gate. If route.skill_name is ideator, do not run prerequisite again. For net-new project starts where scaffold and prerequisite just completed in-thread, hand off with: Start a new chat and either say \"help me define my project\" or share your project brief. In later chats, if route.skill_name is ideator and the user asks to define the project or provides a brief, invoke skills/ideator/SKILL.md. If user intent indicates resuming/continuing work or asking progress, invoke skills/project-progress/SKILL.md first, report current phase, then route to the next step. If the user manually requests a Cadence subskill, resolve PROJECT_ROOT with scripts/resolve-project-root.py and then run scripts/assert-workflow-route.py --skill-name <subskill> --project-root \"$PROJECT_ROOT\" before any state-changing actions."
4
+ default_prompt: "Use Cadence to guide this project from lifecycle setup through phased execution, traceability, audit, and milestone completion. Always read and apply the active SOUL persona from .cadence/SOUL.json (fallback: SOUL.json). Keep user-facing responses concise and outcome-focused, and never expose internal skill-routing or command-execution traces unless the user explicitly asks. At the start of each Cadence turn, run scripts/check-project-repo-status.py, run scaffold only when .cadence is missing, then run scripts/read-workflow-state.py and treat route.skill_name as authoritative for the next state-changing skill. Invoke skills/prerequisite-gate/SKILL.md only when route.skill_name is prerequisite-gate. If route.skill_name is ideator, do not run prerequisite again. For net-new project starts where scaffold and prerequisite just completed in-thread, hand off with: Start a new chat and either say \"help me define my project\" or share your project brief. In later chats, if route.skill_name is ideator and the user asks to define the project or provides a brief, invoke skills/ideator/SKILL.md. If route.skill_name is researcher, invoke skills/researcher/SKILL.md and enforce one pass per conversation; when more passes remain, end with: Start a new chat and say \"continue research\". If user intent indicates resuming/continuing work or asking progress, invoke skills/project-progress/SKILL.md first, report current phase, then route to the next step. If the user manually requests a Cadence subskill, resolve PROJECT_ROOT with scripts/resolve-project-root.py and then run scripts/assert-workflow-route.py --skill-name <subskill> --project-root \"$PROJECT_ROOT\" before any state-changing actions."
@@ -2,11 +2,12 @@
2
2
  "prerequisites-pass": false,
3
3
  "state": {
4
4
  "ideation-completed": false,
5
+ "research-completed": false,
5
6
  "cadence-scripts-dir": "",
6
7
  "repo-enabled": false
7
8
  },
8
9
  "workflow": {
9
- "schema_version": 2,
10
+ "schema_version": 3,
10
11
  "plan": [
11
12
  {
12
13
  "id": "milestone-foundation",
@@ -52,6 +53,16 @@
52
53
  "skill_path": "skills/ideator/SKILL.md",
53
54
  "reason": "Ideation has not been completed yet."
54
55
  }
56
+ },
57
+ {
58
+ "id": "task-research",
59
+ "kind": "task",
60
+ "title": "Research ideation agenda",
61
+ "route": {
62
+ "skill_name": "researcher",
63
+ "skill_path": "skills/researcher/SKILL.md",
64
+ "reason": "Ideation research agenda has not been completed yet."
65
+ }
55
66
  }
56
67
  ]
57
68
  }
@@ -73,6 +84,30 @@
73
84
  "blocks": [],
74
85
  "entity_registry": [],
75
86
  "topic_index": {}
87
+ },
88
+ "research_execution": {
89
+ "version": 1,
90
+ "status": "pending",
91
+ "planning": {
92
+ "target_effort_per_pass": 12,
93
+ "max_topics_per_pass": 4,
94
+ "latest_round": 0
95
+ },
96
+ "summary": {
97
+ "topic_total": 0,
98
+ "topic_complete": 0,
99
+ "topic_needs_followup": 0,
100
+ "topic_pending": 0,
101
+ "pass_pending": 0,
102
+ "pass_complete": 0,
103
+ "next_pass_id": ""
104
+ },
105
+ "topic_status": {},
106
+ "pass_queue": [],
107
+ "pass_history": [],
108
+ "source_registry": [],
109
+ "handoff_required": false,
110
+ "handoff_message": "Start a new chat and say \"continue research\"."
76
111
  }
77
112
  }
78
113
  }
@@ -115,6 +115,12 @@
115
115
  "checkpoints": {
116
116
  "progress-checked": "check progress and route next action"
117
117
  }
118
+ },
119
+ "researcher": {
120
+ "description": "Ideation research pass execution",
121
+ "checkpoints": {
122
+ "research-pass-recorded": "persist one ideation research pass"
123
+ }
118
124
  }
119
125
  }
120
126
  }
@@ -9,6 +9,10 @@ from typing import Any
9
9
 
10
10
  RESEARCH_SCHEMA_VERSION = 1
11
11
  PRIORITY_LEVELS = {"low", "medium", "high"}
12
+ RESEARCH_EXECUTION_SCHEMA_VERSION = 1
13
+ RESEARCH_EXECUTION_STATUSES = {"pending", "in_progress", "complete"}
14
+ RESEARCH_TOPIC_STATUSES = {"pending", "in_progress", "needs_followup", "complete"}
15
+ DEFAULT_RESEARCH_HANDOFF_MESSAGE = 'Start a new chat and say "continue research".'
12
16
 
13
17
 
14
18
  class ResearchAgendaValidationError(ValueError):
@@ -73,6 +77,269 @@ def default_research_agenda() -> dict[str, Any]:
73
77
  }
74
78
 
75
79
 
80
+ def default_research_execution() -> dict[str, Any]:
81
+ return {
82
+ "version": RESEARCH_EXECUTION_SCHEMA_VERSION,
83
+ "status": "pending",
84
+ "planning": {
85
+ "target_effort_per_pass": 12,
86
+ "max_topics_per_pass": 4,
87
+ "latest_round": 0,
88
+ },
89
+ "summary": {
90
+ "topic_total": 0,
91
+ "topic_complete": 0,
92
+ "topic_needs_followup": 0,
93
+ "topic_pending": 0,
94
+ "pass_pending": 0,
95
+ "pass_complete": 0,
96
+ "next_pass_id": "",
97
+ },
98
+ "topic_status": {},
99
+ "pass_queue": [],
100
+ "pass_history": [],
101
+ "source_registry": [],
102
+ "handoff_required": False,
103
+ "handoff_message": DEFAULT_RESEARCH_HANDOFF_MESSAGE,
104
+ }
105
+
106
+
107
+ def _coerce_research_execution_status(value: Any) -> str:
108
+ status = _string(value, "pending").lower()
109
+ if status not in RESEARCH_EXECUTION_STATUSES:
110
+ return "pending"
111
+ return status
112
+
113
+
114
+ def _coerce_research_topic_status(value: Any) -> str:
115
+ status = _string(value, "pending").lower()
116
+ if status not in RESEARCH_TOPIC_STATUSES:
117
+ return "pending"
118
+ return status
119
+
120
+
121
+ def _agenda_topic_index(agenda: dict[str, Any]) -> dict[str, dict[str, Any]]:
122
+ blocks = agenda.get("blocks")
123
+ if not isinstance(blocks, list):
124
+ return {}
125
+
126
+ index: dict[str, dict[str, Any]] = {}
127
+ for block in blocks:
128
+ if not isinstance(block, dict):
129
+ continue
130
+
131
+ block_id = _string(block.get("block_id"))
132
+ block_title = _string(block.get("title"))
133
+ topics = block.get("topics")
134
+ if not isinstance(topics, list):
135
+ continue
136
+
137
+ for topic in topics:
138
+ if not isinstance(topic, dict):
139
+ continue
140
+ topic_id = _string(topic.get("topic_id"))
141
+ if not topic_id:
142
+ continue
143
+ index[topic_id] = {
144
+ "topic_id": topic_id,
145
+ "title": _string(topic.get("title"), topic_id),
146
+ "priority": _string(topic.get("priority"), "medium").lower(),
147
+ "category": _string(topic.get("category"), "general"),
148
+ "research_questions": _string_list(topic.get("research_questions")),
149
+ "keywords": _string_list(topic.get("keywords")),
150
+ "related_entities": _string_list(topic.get("related_entities")),
151
+ "block_id": block_id,
152
+ "block_title": block_title,
153
+ }
154
+ return index
155
+
156
+
157
+ def _normalize_research_execution(agenda: dict[str, Any], raw_execution: Any) -> dict[str, Any]:
158
+ topic_index = _agenda_topic_index(agenda)
159
+ normalized = default_research_execution()
160
+ if not isinstance(raw_execution, dict):
161
+ raw_execution = {}
162
+
163
+ normalized["status"] = _coerce_research_execution_status(raw_execution.get("status"))
164
+ normalized["handoff_required"] = bool(raw_execution.get("handoff_required", False))
165
+ handoff_message = _string(raw_execution.get("handoff_message"), DEFAULT_RESEARCH_HANDOFF_MESSAGE)
166
+ normalized["handoff_message"] = handoff_message or DEFAULT_RESEARCH_HANDOFF_MESSAGE
167
+
168
+ planning = raw_execution.get("planning")
169
+ planning = dict(planning) if isinstance(planning, dict) else {}
170
+ try:
171
+ target_effort = int(planning.get("target_effort_per_pass", 12))
172
+ except (TypeError, ValueError):
173
+ target_effort = 12
174
+ if target_effort < 1:
175
+ target_effort = 1
176
+
177
+ try:
178
+ max_topics = int(planning.get("max_topics_per_pass", 4))
179
+ except (TypeError, ValueError):
180
+ max_topics = 4
181
+ if max_topics < 1:
182
+ max_topics = 1
183
+
184
+ try:
185
+ latest_round = int(planning.get("latest_round", 0))
186
+ except (TypeError, ValueError):
187
+ latest_round = 0
188
+ if latest_round < 0:
189
+ latest_round = 0
190
+
191
+ normalized["planning"] = {
192
+ "target_effort_per_pass": target_effort,
193
+ "max_topics_per_pass": max_topics,
194
+ "latest_round": latest_round,
195
+ }
196
+
197
+ raw_topic_status = raw_execution.get("topic_status")
198
+ raw_topic_status = dict(raw_topic_status) if isinstance(raw_topic_status, dict) else {}
199
+ topic_status: dict[str, dict[str, Any]] = {}
200
+ for topic_id, topic_meta in topic_index.items():
201
+ existing = raw_topic_status.get(topic_id)
202
+ existing = dict(existing) if isinstance(existing, dict) else {}
203
+ status = _coerce_research_topic_status(existing.get("status"))
204
+
205
+ try:
206
+ passes_attempted = int(existing.get("passes_attempted", 0))
207
+ except (TypeError, ValueError):
208
+ passes_attempted = 0
209
+ if passes_attempted < 0:
210
+ passes_attempted = 0
211
+
212
+ topic_status[topic_id] = {
213
+ "topic_id": topic_id,
214
+ "title": topic_meta["title"],
215
+ "status": status,
216
+ "passes_attempted": passes_attempted,
217
+ "last_pass_id": _string(existing.get("last_pass_id")),
218
+ "latest_summary": _string(existing.get("latest_summary")),
219
+ "unresolved_questions": _string_list(existing.get("unresolved_questions")),
220
+ "source_ids": _string_list(existing.get("source_ids")),
221
+ "updated_at": _string(existing.get("updated_at")),
222
+ }
223
+
224
+ raw_queue = raw_execution.get("pass_queue")
225
+ raw_queue = list(raw_queue) if isinstance(raw_queue, list) else []
226
+ pass_queue: list[dict[str, Any]] = []
227
+ for entry in raw_queue:
228
+ if not isinstance(entry, dict):
229
+ continue
230
+ pass_id = _string(entry.get("pass_id"))
231
+ if not pass_id:
232
+ continue
233
+ topic_ids = [topic_id for topic_id in _string_list(entry.get("topic_ids")) if topic_id in topic_index]
234
+ if not topic_ids:
235
+ continue
236
+ status = _string(entry.get("status"), "pending").lower()
237
+ if status not in {"pending", "in_progress"}:
238
+ status = "pending"
239
+
240
+ try:
241
+ round_number = int(entry.get("round", 0))
242
+ except (TypeError, ValueError):
243
+ round_number = 0
244
+ if round_number < 0:
245
+ round_number = 0
246
+
247
+ try:
248
+ planned_effort = int(entry.get("planned_effort", 0))
249
+ except (TypeError, ValueError):
250
+ planned_effort = 0
251
+ if planned_effort < 0:
252
+ planned_effort = 0
253
+
254
+ pass_queue.append(
255
+ {
256
+ "pass_id": pass_id,
257
+ "round": round_number,
258
+ "status": status,
259
+ "topic_ids": topic_ids,
260
+ "planned_effort": planned_effort,
261
+ "created_at": _string(entry.get("created_at")),
262
+ "started_at": _string(entry.get("started_at")),
263
+ }
264
+ )
265
+
266
+ raw_history = raw_execution.get("pass_history")
267
+ raw_history = list(raw_history) if isinstance(raw_history, list) else []
268
+ pass_history: list[dict[str, Any]] = []
269
+ for entry in raw_history:
270
+ if not isinstance(entry, dict):
271
+ continue
272
+ pass_id = _string(entry.get("pass_id"))
273
+ if not pass_id:
274
+ continue
275
+ pass_history.append(dict(entry))
276
+
277
+ raw_sources = raw_execution.get("source_registry")
278
+ raw_sources = list(raw_sources) if isinstance(raw_sources, list) else []
279
+ source_registry: list[dict[str, Any]] = []
280
+ for source in raw_sources:
281
+ if not isinstance(source, dict):
282
+ continue
283
+ source_id = _string(source.get("source_id"))
284
+ url = _string(source.get("url"))
285
+ if not source_id or not url:
286
+ continue
287
+ source_registry.append(
288
+ {
289
+ "source_id": source_id,
290
+ "url": url,
291
+ "title": _string(source.get("title")),
292
+ "publisher": _string(source.get("publisher")),
293
+ "published_at": _string(source.get("published_at")),
294
+ "notes": _string(source.get("notes")),
295
+ "topic_ids": [topic_id for topic_id in _string_list(source.get("topic_ids")) if topic_id in topic_index],
296
+ "pass_id": _string(source.get("pass_id")),
297
+ "captured_at": _string(source.get("captured_at")),
298
+ }
299
+ )
300
+
301
+ normalized["version"] = RESEARCH_EXECUTION_SCHEMA_VERSION
302
+ normalized["topic_status"] = topic_status
303
+ normalized["pass_queue"] = pass_queue
304
+ normalized["pass_history"] = pass_history
305
+ normalized["source_registry"] = source_registry
306
+
307
+ total_topics = len(topic_status)
308
+ topic_complete = len([entry for entry in topic_status.values() if entry.get("status") == "complete"])
309
+ topic_needs_followup = len(
310
+ [entry for entry in topic_status.values() if entry.get("status") == "needs_followup"]
311
+ )
312
+ topic_pending = total_topics - topic_complete - topic_needs_followup
313
+
314
+ in_progress_queue = [entry for entry in pass_queue if entry.get("status") == "in_progress"]
315
+ pending_queue = [entry for entry in pass_queue if entry.get("status") == "pending"]
316
+ next_pass_id = in_progress_queue[0]["pass_id"] if in_progress_queue else (pending_queue[0]["pass_id"] if pending_queue else "")
317
+
318
+ if total_topics == 0:
319
+ execution_status = "pending"
320
+ elif topic_complete == total_topics:
321
+ execution_status = "complete"
322
+ elif in_progress_queue or pending_queue:
323
+ execution_status = "in_progress"
324
+ else:
325
+ execution_status = "pending"
326
+
327
+ normalized["status"] = execution_status
328
+ if execution_status == "complete":
329
+ normalized["handoff_required"] = False
330
+
331
+ normalized["summary"] = {
332
+ "topic_total": total_topics,
333
+ "topic_complete": topic_complete,
334
+ "topic_needs_followup": topic_needs_followup,
335
+ "topic_pending": max(topic_pending, 0),
336
+ "pass_pending": len(pass_queue),
337
+ "pass_complete": len(pass_history),
338
+ "next_pass_id": next_pass_id,
339
+ }
340
+ return normalized
341
+
342
+
76
343
  def ensure_ideation_research_defaults(ideation: Any) -> dict[str, Any]:
77
344
  if not isinstance(ideation, dict):
78
345
  ideation = {}
@@ -80,6 +347,7 @@ def ensure_ideation_research_defaults(ideation: Any) -> dict[str, Any]:
80
347
  agenda = ideation.get("research_agenda")
81
348
  if not isinstance(agenda, dict):
82
349
  ideation["research_agenda"] = default_research_agenda()
350
+ ideation["research_execution"] = default_research_execution()
83
351
  return ideation
84
352
 
85
353
  normalized = dict(agenda)
@@ -106,9 +374,48 @@ def ensure_ideation_research_defaults(ideation: Any) -> dict[str, Any]:
106
374
  normalized["summary"] = summary
107
375
 
108
376
  ideation["research_agenda"] = normalized
377
+ ideation["research_execution"] = _normalize_research_execution(
378
+ normalized,
379
+ ideation.get("research_execution"),
380
+ )
109
381
  return ideation
110
382
 
111
383
 
384
+ def reset_research_execution(ideation: Any) -> dict[str, Any]:
385
+ normalized = ensure_ideation_research_defaults(ideation)
386
+ agenda = normalized.get("research_agenda")
387
+ agenda = dict(agenda) if isinstance(agenda, dict) else default_research_agenda()
388
+
389
+ topic_index = _agenda_topic_index(agenda)
390
+ execution = default_research_execution()
391
+ execution["topic_status"] = {
392
+ topic_id: {
393
+ "topic_id": topic_id,
394
+ "title": topic.get("title", topic_id),
395
+ "status": "pending",
396
+ "passes_attempted": 0,
397
+ "last_pass_id": "",
398
+ "latest_summary": "",
399
+ "unresolved_questions": [],
400
+ "source_ids": [],
401
+ "updated_at": "",
402
+ }
403
+ for topic_id, topic in topic_index.items()
404
+ }
405
+ execution["summary"] = {
406
+ "topic_total": len(topic_index),
407
+ "topic_complete": 0,
408
+ "topic_needs_followup": 0,
409
+ "topic_pending": len(topic_index),
410
+ "pass_pending": 0,
411
+ "pass_complete": 0,
412
+ "next_pass_id": "",
413
+ }
414
+
415
+ normalized["research_execution"] = execution
416
+ return normalized
417
+
418
+
112
419
  def _coerce_entity_refs(raw: Any) -> tuple[list[str], dict[str, str]]:
113
420
  refs = []
114
421
  if isinstance(raw, (list, tuple, set)):
@@ -7,7 +7,11 @@ import subprocess
7
7
  import sys
8
8
  from pathlib import Path
9
9
 
10
- from ideation_research import ResearchAgendaValidationError, normalize_ideation_research
10
+ from ideation_research import (
11
+ ResearchAgendaValidationError,
12
+ normalize_ideation_research,
13
+ reset_research_execution,
14
+ )
11
15
  from workflow_state import default_data, reconcile_workflow_state
12
16
 
13
17
 
@@ -82,6 +86,7 @@ def parse_payload(args):
82
86
 
83
87
  def apply_completion_state(data, completion_state):
84
88
  state = data.setdefault("state", {})
89
+ state["research-completed"] = False
85
90
  if completion_state == "complete":
86
91
  state["ideation-completed"] = True
87
92
  elif completion_state == "incomplete":
@@ -136,6 +141,7 @@ def main():
136
141
  data.get("ideation", {}),
137
142
  require_topics=require_research_topics,
138
143
  )
144
+ data["ideation"] = reset_research_execution(data.get("ideation", {}))
139
145
  except ResearchAgendaValidationError as exc:
140
146
  print(str(exc), file=sys.stderr)
141
147
  return 2