prizmkit 1.1.122 → 1.1.124

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.122",
3
- "bundledAt": "2026-07-11T17:19:34.691Z",
4
- "bundledFrom": "fbcb3f4"
2
+ "frameworkVersion": "1.1.124",
3
+ "bundledAt": "2026-07-12T05:54:30.044Z",
4
+ "bundledFrom": "f15301f"
5
5
  }
@@ -20,6 +20,69 @@ function toClaudePrizmkitCommand(sub) {
20
20
  return `prizmkit-${normalized}`;
21
21
  }
22
22
 
23
+ const REVIEWER_ROLE_SKILL = 'prizmkit-code-review';
24
+ const REVIEWER_ROLE_FILE = 'prizmkit-code-reviewer.md';
25
+ const REVIEWER_REQUIRED_UNAVAILABLE = new Set([
26
+ 'workspace-mutation',
27
+ 'shell-execution',
28
+ 'downstream-execution-creation',
29
+ 'delegation-equivalent-behavior',
30
+ 'orchestration-reentry',
31
+ 'skill-invocation',
32
+ ]);
33
+ const CLAUDE_OBSERVATIONAL_TOOLS = new Map([
34
+ ['read-files', 'Read'],
35
+ ['search-content', 'Grep'],
36
+ ['search-paths', 'Glob'],
37
+ ]);
38
+
39
+ function buildReviewerRoleDefinition(roleManifest) {
40
+ if (!roleManifest || roleManifest.role_name !== 'prizmkit-code-reviewer') {
41
+ throw new Error('prizmkit-code-review requires assets/reviewer-role.json');
42
+ }
43
+ const unavailable = new Set(roleManifest.unavailable_capabilities || []);
44
+ for (const capability of REVIEWER_REQUIRED_UNAVAILABLE) {
45
+ if (!unavailable.has(capability)) {
46
+ throw new Error(`Reviewer role must make ${capability} unavailable`);
47
+ }
48
+ }
49
+ if (roleManifest.execution_mode !== 'foreground-single-authority'
50
+ || roleManifest.max_descendant_execution_units !== 0
51
+ || roleManifest.max_delegation_depth !== 0) {
52
+ throw new Error('Reviewer role violates the single-authority execution topology');
53
+ }
54
+
55
+ const tools = (roleManifest.observational_capabilities || []).map(capability => {
56
+ const tool = CLAUDE_OBSERVATIONAL_TOOLS.get(capability);
57
+ if (!tool) throw new Error(`Unsupported Reviewer observational capability: ${capability}`);
58
+ return tool;
59
+ });
60
+ if (tools.length === 0) throw new Error('Reviewer role requires observational capabilities');
61
+
62
+ return `---
63
+ name: ${roleManifest.role_name}
64
+ description: ${roleManifest.description}
65
+ tools: ${tools.join(', ')}
66
+ permissionMode: dontAsk
67
+ maxTurns: 200
68
+ ---
69
+
70
+ You are a capability-restricted read-only Reviewer execution role.
71
+
72
+ Read and follow the complete prompt supplied by the Main Agent. Perform the complete review yourself. Your tool allowlist intentionally excludes workspace mutation, shell execution, skill invocation, downstream execution creation, delegation-equivalent behavior, and orchestration re-entry.
73
+
74
+ If the supplied review cannot be completed with the available observational capabilities and current execution context, return \`REVIEW_INFRASTRUCTURE_BLOCKED\`. Do not delegate, request a helper, mutate the workspace, or claim \`PASS\` from incomplete work.
75
+ `;
76
+ }
77
+
78
+ export function getClaudeSkillAgentDefinitions(skillName, roleManifest = null) {
79
+ if (skillName !== REVIEWER_ROLE_SKILL) return [];
80
+ return [{
81
+ fileName: REVIEWER_ROLE_FILE,
82
+ content: buildReviewerRoleDefinition(roleManifest),
83
+ }];
84
+ }
85
+
23
86
  /**
24
87
  * Convert a core SKILL.md to Claude Code command.md format.
25
88
  * @param {string} skillContent - Content of the core SKILL.md
@@ -97,6 +160,20 @@ export async function installCommand(corePath, targetRoot) {
97
160
  for (const subdir of subdirs) {
98
161
  cpSync(path.join(corePath, subdir), path.join(targetDir, subdir), { recursive: true });
99
162
  }
163
+
164
+ // Install platform-native capability roles generated from canonical skill semantics.
165
+ const roleManifestPath = path.join(corePath, 'assets', 'reviewer-role.json');
166
+ const roleManifest = existsSync(roleManifestPath)
167
+ ? JSON.parse(await readFile(roleManifestPath, 'utf8'))
168
+ : null;
169
+ const agentDefinitions = getClaudeSkillAgentDefinitions(skillName, roleManifest);
170
+ if (agentDefinitions.length > 0) {
171
+ const agentsDir = path.join(targetRoot, '.claude', 'agents');
172
+ mkdirSync(agentsDir, { recursive: true });
173
+ for (const definition of agentDefinitions) {
174
+ await writeFile(path.join(agentsDir, definition.fileName), definition.content);
175
+ }
176
+ }
100
177
  } else {
101
178
  // Single file command
102
179
  const targetDir = path.join(targetRoot, COMMANDS_DIR);
@@ -11,14 +11,20 @@ from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
 
13
13
 
14
- HIDDEN_TOOL_WORKTREE_EXCLUDES = (
14
+ PIPELINE_TRANSIENT_EXCLUDES = (
15
15
  ":(top,exclude).prizmkit-worktree",
16
- ":(top,exclude).skills-lock.json",
17
- ":(top,exclude).AGENTS.md",
18
16
  ":(top,exclude,glob).*/worktree",
19
17
  ":(top,exclude,glob).*/worktree/**",
20
18
  ":(top,exclude,glob).*/worktrees",
21
19
  ":(top,exclude,glob).*/worktrees/**",
20
+ ":(top,exclude).prizmkit/state",
21
+ ":(top,exclude,glob).prizmkit/state/**",
22
+ )
23
+
24
+ HIDDEN_TOOL_WORKTREE_EXCLUDES = (
25
+ *PIPELINE_TRANSIENT_EXCLUDES,
26
+ ":(top,exclude).skills-lock.json",
27
+ ":(top,exclude).AGENTS.md",
22
28
  ":(top,exclude).claude",
23
29
  ":(top,exclude,glob).claude/**",
24
30
  ":(top,exclude).codebuddy",
@@ -31,12 +37,17 @@ HIDDEN_TOOL_WORKTREE_EXCLUDES = (
31
37
  ":(top,exclude,glob).prizmkit/prizm-docs/**",
32
38
  ":(top,exclude).prizmkit/config.json",
33
39
  ":(top,exclude).prizmkit/manifest.json",
34
- ":(top,exclude).prizmkit/state",
35
- ":(top,exclude,glob).prizmkit/state/**",
36
40
  ":(top,exclude).prizmkit/dev-pipeline",
37
41
  ":(top,exclude,glob).prizmkit/dev-pipeline/**",
38
42
  )
39
43
 
44
+ UNTRACKED_TRANSIENT_EXCLUDES = (
45
+ *HIDDEN_TOOL_WORKTREE_EXCLUDES,
46
+ ":(top,exclude,glob)**/__pycache__",
47
+ ":(top,exclude,glob)**/__pycache__/**",
48
+ ":(top,exclude,glob)**/*.py[cod]",
49
+ )
50
+
40
51
  PIPELINE_FALLBACK_GIT_NAME = "PrizmKit Pipeline"
41
52
  PIPELINE_FALLBACK_GIT_EMAIL = "prizmkit-pipeline@example.invalid"
42
53
 
@@ -11,10 +11,37 @@ from collections.abc import Sequence
11
11
  from dataclasses import dataclass
12
12
  from pathlib import Path
13
13
 
14
- from .gitops import HIDDEN_TOOL_WORKTREE_EXCLUDES, GitCommandResult, git_status_safe, run_git_command
14
+ from .gitops import (
15
+ HIDDEN_TOOL_WORKTREE_EXCLUDES,
16
+ UNTRACKED_TRANSIENT_EXCLUDES,
17
+ GitCommandResult,
18
+ git_status_safe,
19
+ run_git_command,
20
+ )
15
21
  from .runner_models import RunnerFamily
16
22
 
17
23
 
24
+ LOCAL_ONLY_PLATFORM_PATHS = (
25
+ ".claude/settings.local.json",
26
+ ".codebuddy/settings.local.json",
27
+ ".codex/settings.local.json",
28
+ ".agents/settings.local.json",
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PreflightWorkspace:
34
+ """Exact Git paths participating in startup preservation."""
35
+
36
+ tracked: tuple[str, ...]
37
+ visible_untracked: tuple[str, ...]
38
+ unsafe_local: tuple[str, ...]
39
+
40
+ @property
41
+ def preservable(self) -> tuple[str, ...]:
42
+ return _unique_paths((*self.tracked, *self.visible_untracked))
43
+
44
+
18
45
  @dataclass(frozen=True)
19
46
  class BookkeepingResult:
20
47
  """Result from an optional bookkeeping commit boundary."""
@@ -42,6 +69,81 @@ def _repo_path(project_root: Path, path: Path) -> str:
42
69
  return path.as_posix()
43
70
 
44
71
 
72
+ def _tracked_status_result(project_root: Path) -> GitCommandResult:
73
+ return run_git_command(project_root, ("status", "--porcelain=v1", "-z", "--untracked-files=no", "--", "."))
74
+
75
+
76
+ def _untracked_status_result(project_root: Path) -> GitCommandResult:
77
+ return run_git_command(
78
+ project_root,
79
+ ("ls-files", "--others", "--exclude-standard", "-z", "--", ".", *UNTRACKED_TRANSIENT_EXCLUDES),
80
+ )
81
+
82
+
83
+ def _unique_paths(paths: Sequence[str]) -> tuple[str, ...]:
84
+ return tuple(dict.fromkeys(path for path in paths if path))
85
+
86
+
87
+ def _literal_pathspecs(paths: Sequence[str]) -> tuple[str, ...]:
88
+ return tuple(f":(top,literal){path}" for path in paths)
89
+
90
+
91
+ def _status_record_paths(stdout: str) -> tuple[str, ...]:
92
+ """Decode exact paths from NUL-delimited porcelain-v1 records."""
93
+ records = stdout.split("\0")
94
+ changed: list[str] = []
95
+ index = 0
96
+ while index < len(records):
97
+ record = records[index]
98
+ index += 1
99
+ if not record:
100
+ continue
101
+ if len(record) < 4 or record[2] != " ":
102
+ continue
103
+ status = record[:2]
104
+ changed.append(record[3:])
105
+ if "R" in status or "C" in status:
106
+ if index < len(records) and records[index]:
107
+ changed.append(records[index])
108
+ index += 1
109
+ return _unique_paths(changed)
110
+
111
+
112
+ def _preflight_workspace(project_root: Path) -> tuple[PreflightWorkspace | None, GitCommandResult | None]:
113
+ tracked_result = _tracked_status_result(project_root)
114
+ if tracked_result.return_code != 0:
115
+ return None, tracked_result
116
+ untracked_result = _untracked_status_result(project_root)
117
+ if untracked_result.return_code != 0:
118
+ return None, untracked_result
119
+
120
+ tracked = _status_record_paths(tracked_result.stdout)
121
+ visible_untracked = _unique_paths(untracked_result.stdout.split("\0"))
122
+ unsafe_local = tuple(path for path in tracked if path in LOCAL_ONLY_PLATFORM_PATHS)
123
+ return PreflightWorkspace(tracked, visible_untracked, unsafe_local), None
124
+
125
+
126
+ def _preflight_failure(message: str) -> GitCommandResult:
127
+ return GitCommandResult(args=("status", "preflight"), return_code=1, stdout="", stderr=message)
128
+
129
+
130
+ def _remaining_preflight_changes(project_root: Path) -> GitCommandResult:
131
+ workspace, failure = _preflight_workspace(project_root)
132
+ if failure is not None:
133
+ return failure
134
+ assert workspace is not None
135
+ if workspace.unsafe_local:
136
+ return _preflight_failure(
137
+ "Refusing to auto-commit local-only platform settings: " + ", ".join(workspace.unsafe_local)
138
+ )
139
+ return GitCommandResult(
140
+ args=("status", "preflight"),
141
+ return_code=0,
142
+ stdout="\0".join(workspace.preservable),
143
+ stderr="",
144
+ )
145
+
146
+
45
147
  def _visible_status_result(project_root: Path) -> GitCommandResult:
46
148
  return run_git_command(project_root, ("status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
47
149
 
@@ -85,23 +187,33 @@ def commit_bookkeeping_changes(
85
187
 
86
188
 
87
189
  def commit_preflight_ready_changes(project_root: Path, item_id: str) -> BookkeepingResult:
88
- """Preserve visible pre-existing changes before task branch setup."""
89
- visible_status = _visible_status_result(project_root)
90
- if visible_status.return_code != 0:
91
- return BookkeepingResult(attempted=True, committed=False, result=visible_status)
92
- paths = _paths_from_status(visible_status.stdout)
190
+ """Preserve tracked changes and visible untracked files before task branch setup."""
191
+ workspace, failure = _preflight_workspace(project_root)
192
+ if failure is not None:
193
+ return BookkeepingResult(attempted=True, committed=False, result=failure)
194
+ assert workspace is not None
195
+ if workspace.unsafe_local:
196
+ return BookkeepingResult(
197
+ attempted=True,
198
+ committed=False,
199
+ result=_preflight_failure(
200
+ "Refusing to auto-commit local-only platform settings: " + ", ".join(workspace.unsafe_local)
201
+ ),
202
+ )
203
+ paths = workspace.preservable
93
204
  if not paths:
94
205
  return BookkeepingResult(attempted=False, committed=False)
95
- add_result = run_git_command(project_root, ("add", "-A", "-f", "--", *paths))
206
+ pathspecs = _literal_pathspecs(paths)
207
+ add_result = run_git_command(project_root, ("add", "-A", "-f", "--", *pathspecs))
96
208
  if add_result.return_code != 0:
97
209
  return BookkeepingResult(attempted=True, committed=False, result=add_result)
98
210
  result = run_git_command(
99
211
  project_root,
100
- ("commit", "--no-verify", "-m", f"chore: ready for {item_id}", "--", *paths),
212
+ ("commit", "--no-verify", "-m", f"chore: ready for {item_id}", "--", *pathspecs),
101
213
  )
102
214
  if result.return_code != 0:
103
215
  return BookkeepingResult(attempted=True, committed=False, result=result)
104
- clean_result = _visible_status_result(project_root)
216
+ clean_result = _remaining_preflight_changes(project_root)
105
217
  if clean_result.return_code != 0 or clean_result.stdout.strip():
106
218
  return BookkeepingResult(attempted=True, committed=False, result=clean_result)
107
219
  return BookkeepingResult(attempted=True, committed=True, result=result)
@@ -107,31 +107,39 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
107
107
  from .runner_models import PipelineRunResult
108
108
 
109
109
  processed = 0
110
- details: list[str] = []
110
+ item_statuses: dict[str, str] = {}
111
111
  if invocation.item_id:
112
112
  try:
113
113
  status = _process_item(family, invocation, invocation.item_id, paths, initial_metadata={})
114
114
  except KeyboardInterrupt:
115
- return PipelineRunResult(False, 1, "interrupted", "interrupted", tuple(details))
116
- return PipelineRunResult(status == "completed", 1, "single_item_done", status, tuple(details))
115
+ return PipelineRunResult(False, 1, "interrupted", "interrupted")
116
+ return PipelineRunResult(status == "completed", 1, "single_item_done", status)
117
117
 
118
118
  while True:
119
119
  next_result = get_next(family, invocation, paths.project_root)
120
+ details = _render_item_statuses(item_statuses)
120
121
  if not next_result.ok:
121
122
  return PipelineRunResult(False, processed, "get_next_failed", details=(next_result.text,))
122
123
  marker = next_result.stdout.strip()
123
124
  if marker == "PIPELINE_COMPLETE":
124
- if _has_non_terminal_details(details):
125
+ blocking_statuses = _blocking_statuses(family, item_statuses)
126
+ if blocking_statuses:
125
127
  return PipelineRunResult(
126
128
  False,
127
129
  processed,
128
130
  "pipeline_complete_with_non_terminal_history",
129
- _last_detail_status(details),
130
- tuple(details),
131
+ blocking_statuses[-1],
132
+ details,
131
133
  )
132
- return PipelineRunResult(True, processed, "pipeline_complete", details=tuple(details))
134
+ return PipelineRunResult(
135
+ True,
136
+ processed,
137
+ "pipeline_complete",
138
+ _last_item_status(item_statuses),
139
+ details,
140
+ )
133
141
  if marker == "PIPELINE_BLOCKED":
134
- return PipelineRunResult(False, processed, "pipeline_blocked", details=tuple(details))
142
+ return PipelineRunResult(False, processed, "pipeline_blocked", details=details)
135
143
  if not isinstance(next_result.data, dict):
136
144
  return PipelineRunResult(False, processed, "invalid_get_next_output", details=(marker[:500],))
137
145
  item_id = str(next_result.data.get(f"{family.kind}_id") or next_result.data.get("feature_id") or next_result.data.get("bug_id") or next_result.data.get("refactor_id") or "")
@@ -141,50 +149,50 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
141
149
  final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
142
150
  except KeyboardInterrupt:
143
151
  processed += 1
144
- details.append(f"{item_id}:interrupted")
145
- return PipelineRunResult(False, processed, "interrupted", "interrupted", tuple(details))
152
+ item_statuses[item_id] = "interrupted"
153
+ return PipelineRunResult(
154
+ False,
155
+ processed,
156
+ "interrupted",
157
+ "interrupted",
158
+ _render_item_statuses(item_statuses),
159
+ )
146
160
  processed += 1
147
- details.append(f"{item_id}:{final_status}")
148
- if final_status == "completed":
149
- _emit_info(f"Pausing 5s before next {family.kind}...")
161
+ item_statuses[item_id] = final_status
162
+ if final_status in _completion_compatible_statuses(family):
163
+ if final_status == "completed":
164
+ _emit_info(f"Pausing 5s before next {family.kind}...")
150
165
  continue
151
166
  if final_status == "pending":
152
167
  continue
168
+ details = _render_item_statuses(item_statuses)
153
169
  if RunnerEnvironment.from_env().stop_on_failure:
154
- return PipelineRunResult(False, processed, "stop_on_failure", final_status, tuple(details))
170
+ return PipelineRunResult(False, processed, "stop_on_failure", final_status, details)
155
171
  if final_status in {"failed", "needs_info"}:
156
- return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
172
+ return PipelineRunResult(False, processed, "item_failed", final_status, details)
173
+
157
174
 
175
+ _COMPLETION_COMPATIBLE_STATUSES = {"completed", "skipped", "auto_skipped"}
158
176
 
159
- _NON_TERMINAL_DETAIL_STATUSES = {
160
- "pending",
161
- "retryable",
162
- "in_progress",
163
- "infra_error",
164
- "context_overflow",
165
- "stalled_context_continuation",
166
- "crashed",
167
- "timed_out",
168
- "failed",
169
- "needs_info",
170
- "merge_conflict",
171
- "interrupted",
172
- "unknown",
173
- }
174
177
 
178
+ def _completion_compatible_statuses(family: RunnerFamily) -> set[str]:
179
+ statuses = set(_COMPLETION_COMPATIBLE_STATUSES)
180
+ if family.kind == "feature":
181
+ statuses.add("split")
182
+ return statuses
175
183
 
176
- def _detail_status(detail: str) -> str:
177
- return detail.rsplit(":", 1)[-1] if ":" in detail else detail
178
184
 
185
+ def _render_item_statuses(item_statuses: Mapping[str, str]) -> tuple[str, ...]:
186
+ return tuple(f"{item_id}:{status}" for item_id, status in item_statuses.items())
179
187
 
180
- def _last_detail_status(details: list[str]) -> str:
181
- if not details:
182
- return ""
183
- return _detail_status(details[-1])
188
+
189
+ def _last_item_status(item_statuses: Mapping[str, str]) -> str:
190
+ return next(reversed(item_statuses.values()), "")
184
191
 
185
192
 
186
- def _has_non_terminal_details(details: list[str]) -> bool:
187
- return any(_detail_status(detail) in _NON_TERMINAL_DETAIL_STATUSES for detail in details)
193
+ def _blocking_statuses(family: RunnerFamily, item_statuses: Mapping[str, str]) -> tuple[str, ...]:
194
+ compatible = _completion_compatible_statuses(family)
195
+ return tuple(status for status in item_statuses.values() if status not in compatible)
188
196
 
189
197
 
190
198
  _SEPARATOR = "─" * 52