@julioborges/gantry 0.1.0 → 1.0.3

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.
@@ -185,7 +185,7 @@ def main() -> int:
185
185
  print(f" ERROR: {error}")
186
186
  if errors:
187
187
  return 1
188
- return 0 if selected or parked else 2
188
+ return 0 if selected or parked or all(i.done for i in issues.values()) else 2
189
189
 
190
190
 
191
191
  if __name__ == "__main__":
@@ -36,7 +36,7 @@ sys.path.insert(0, str(Path(__file__).parent))
36
36
  import runlog # noqa: E402
37
37
 
38
38
  DECISION_EVENTS = {"PreToolUse", "tool.execute.before"}
39
- SUBAGENT_START_EVENTS = {"SubagentStart"}
39
+ SUBAGENT_START_EVENTS = {"SubagentStart", "invoke_subagent"}
40
40
  SUBAGENT_STOP_EVENTS = {"SubagentStop"}
41
41
  COMPACTION_EVENTS = {"PreCompact", "session.compacted"}
42
42
 
@@ -67,15 +67,16 @@ EVENT_REQUIRED_CONCEPTS = {
67
67
  CONCEPT_FIELD_BY_HARNESS = {
68
68
  "claude-code": {"tool_name": "tool_name", "tool_input": "tool_input", "session_id": "session_id"},
69
69
  "opencode": {"tool_name": "tool", "tool_input": "args", "session_id": "sessionID"},
70
+ "antigravity": {"tool_name": "tool_name", "tool_input": "tool_input", "session_id": "session_id"},
70
71
  }
71
72
  CONCEPT_ALIASES = {
72
- "tool_name": ("tool_name", "tool", "toolName"),
73
- "tool_input": ("tool_input", "args", "toolInput", "input"),
73
+ "tool_name": ("tool_name", "tool", "toolName", "name"),
74
+ "tool_input": ("tool_input", "args", "toolInput", "input", "parameters", "arguments"),
74
75
  "session_id": ("session_id", "sessionID", "sessionId"),
75
76
  }
76
77
 
77
- MODIFYING_TOOLS = {"edit", "write", "multiedit", "notebookedit", "applypatch", "patch"}
78
- BASH_TOOLS = {"bash", "shell", "exec"}
78
+ MODIFYING_TOOLS = {"edit", "write", "multiedit", "notebookedit", "applypatch", "patch", "writetofile", "replacefilecontent"}
79
+ BASH_TOOLS = {"bash", "shell", "exec", "runcommand"}
79
80
 
80
81
  ISSUE_FILE_RE = re.compile(r"^\d{2,}-[a-z0-9-]+\.md$")
81
82
  STATUS_LINE_RE = re.compile(r"(?m)^Status:\s*(\S+)")
@@ -134,13 +135,21 @@ def tool_input(payload: dict) -> dict:
134
135
 
135
136
 
136
137
  def extract_path(payload: dict, arguments: dict) -> str | None:
137
- value = _first(arguments, ("file_path", "filePath", "path", "filename")) or _first(payload, ("file_path", "path"))
138
+ value = (
139
+ _first(arguments, ("file_path", "filePath", "path", "filename", "TargetFile", "target_file", "targetFile"))
140
+ or _first(payload, ("file_path", "path", "TargetFile", "target_file", "targetFile"))
141
+ )
138
142
  return str(value) if value else None
139
143
 
140
144
 
141
145
  def extract_text(arguments: dict) -> str:
142
146
  parts: list[str] = []
143
- for key in ("old_string", "oldString", "new_string", "newString", "content", "text"):
147
+ for key in (
148
+ "old_string", "oldString", "new_string", "newString", "content", "text",
149
+ "CodeContent", "code_content", "codeContent",
150
+ "TargetContent", "target_content", "targetContent",
151
+ "ReplacementContent", "replacement_content", "replacementContent",
152
+ ):
144
153
  value = arguments.get(key)
145
154
  if isinstance(value, str):
146
155
  parts.append(value)
@@ -158,7 +167,11 @@ def extract_text(arguments: dict) -> str:
158
167
  def extract_new_text(arguments: dict) -> str:
159
168
  """The resulting text a modifying tool would write -- never the text it replaces."""
160
169
  parts: list[str] = []
161
- for key in ("new_string", "newString", "content", "text"):
170
+ for key in (
171
+ "new_string", "newString", "content", "text",
172
+ "CodeContent", "code_content", "codeContent",
173
+ "ReplacementContent", "replacement_content", "replacementContent",
174
+ ):
162
175
  value = arguments.get(key)
163
176
  if isinstance(value, str):
164
177
  parts.append(value)
@@ -182,18 +195,22 @@ def is_draft_safe(new_text: str) -> bool:
182
195
 
183
196
 
184
197
  def extract_command(arguments: dict) -> str | None:
185
- value = _first(arguments, ("command", "cmd"))
198
+ value = _first(arguments, ("command", "cmd", "CommandLine", "command_line", "commandLine"))
186
199
  return str(value) if value else None
187
200
 
188
201
 
189
202
  def payload_cwd(payload: dict) -> str | None:
190
203
  """The harness-reported working directory for this call, if the payload carries one.
191
204
 
192
- Claude Code spells it `cwd`, OpenCode spells it `directory`. Neither is a decision
193
- concept the capability files gate on -- it is only ever used to pick *where* to look
194
- (a git worktree, an Issue path), never to grant or withhold authority on its own.
205
+ Claude Code spells it `cwd`, OpenCode spells it `directory`, Antigravity spells it `Cwd`
206
+ in run_command tool_input. Neither is a decision concept the capability files gate on --
207
+ it is only ever used to pick *where* to look (a git worktree, an Issue path), never to grant
208
+ or withhold authority on its own.
195
209
  """
196
- value = _first(payload, ("cwd", "directory"))
210
+ value = _first(payload, ("cwd", "directory", "Cwd"))
211
+ if not value and isinstance(payload, dict):
212
+ args = tool_input(payload)
213
+ value = _first(args, ("cwd", "directory", "Cwd"))
197
214
  return str(value) if value else None
198
215
 
199
216
 
@@ -218,23 +235,23 @@ def apply_edits(original: str, arguments: dict) -> str | None:
218
235
  """
219
236
  edits = arguments.get("edits")
220
237
  if not isinstance(edits, list):
221
- old = arguments.get("old_string", arguments.get("oldString"))
222
- new = arguments.get("new_string", arguments.get("newString"))
238
+ old = _first(arguments, ("old_string", "oldString", "TargetContent", "target_content", "targetContent"))
239
+ new = _first(arguments, ("new_string", "newString", "ReplacementContent", "replacement_content", "replacementContent"))
223
240
  if not isinstance(old, str) or not isinstance(new, str):
224
241
  return None
225
- replace_all = arguments.get("replace_all", arguments.get("replaceAll"))
242
+ replace_all = arguments.get("replace_all", arguments.get("replaceAll", arguments.get("AllowMultiple", arguments.get("allow_multiple", arguments.get("allowMultiple")))))
226
243
  edits = [{"old_string": old, "new_string": new, "replace_all": replace_all}]
227
244
  text = original
228
245
  for edit in edits:
229
246
  if not isinstance(edit, dict):
230
247
  return None
231
- old = edit.get("old_string", edit.get("oldString"))
232
- new = edit.get("new_string", edit.get("newString"))
248
+ old = _first(edit, ("old_string", "oldString", "TargetContent", "target_content", "targetContent"))
249
+ new = _first(edit, ("new_string", "newString", "ReplacementContent", "replacement_content", "replacementContent"))
233
250
  if not isinstance(old, str) or not isinstance(new, str):
234
251
  return None
235
252
  if old not in text:
236
253
  return None
237
- replace_all = bool(edit.get("replace_all", edit.get("replaceAll")))
254
+ replace_all = bool(edit.get("replace_all", edit.get("replaceAll", edit.get("AllowMultiple", edit.get("allow_multiple", edit.get("allowMultiple"))))))
238
255
  count = -1 if replace_all else 1
239
256
  text = text.replace(old, new, count)
240
257
  return text
@@ -258,7 +275,7 @@ def decide(payload: dict, cwd: Path) -> Decision:
258
275
  if ISSUE_FILE_RE.match(basename):
259
276
  target = Path(path)
260
277
  target = target if target.is_absolute() else cwd / target
261
- if normalized_name in {"write", "multiedit"}:
278
+ if normalized_name in {"write", "multiedit", "writetofile"}:
262
279
  # The draft exemption only ever applies to *creating* a new Issue file.
263
280
  # Once the target exists, its Status/checkbox fields are already under
264
281
  # protection, and the new content must fall through to the same
@@ -266,7 +283,7 @@ def decide(payload: dict, cwd: Path) -> Decision:
266
283
  # never exempted just because the new content, read alone, looks draft-safe.
267
284
  if not target.exists() and is_draft_safe(extract_new_text(arguments)):
268
285
  return Decision(True)
269
- if normalized_name in {"edit", "multiedit"} and target.exists():
286
+ if normalized_name in {"edit", "multiedit", "replacefilecontent"} and target.exists():
270
287
  # Compare the whole file before/after applying the edit in memory, so a
271
288
  # value-only edit (e.g. 'ready-for-agent' -> 'done', or '[ ]' -> '[x]'
272
289
  # without the '- ' scaffolding) is caught even though neither its
@@ -449,7 +466,14 @@ def handle_subagent_event(payload: dict, args: argparse.Namespace, event: str) -
449
466
  return
450
467
  role = None
451
468
  if isinstance(payload, dict):
452
- role = _first(payload, ("role", "subagent_type", "agent_type", "subagentType", "agentType", "description"))
469
+ role = _first(payload, ("role", "subagent_type", "agent_type", "subagentType", "agentType", "description", "Role", "TypeName"))
470
+ if not role:
471
+ args_input = tool_input(payload)
472
+ subagents = args_input.get("Subagents") or args_input.get("subagents")
473
+ if isinstance(subagents, list) and subagents:
474
+ role = _first(subagents[0], ("Role", "role", "TypeName", "typeName"))
475
+ if not role:
476
+ role = _first(args_input, ("Role", "role", "TypeName", "typeName", "description"))
453
477
  record(
454
478
  cwd,
455
479
  root,
@@ -463,6 +487,36 @@ def handle_subagent_event(payload: dict, args: argparse.Namespace, event: str) -
463
487
  )
464
488
 
465
489
 
490
+ def record_invoke_subagent_event(payload: dict, args: argparse.Namespace, cwd: Path, event: str) -> None:
491
+ args_input = tool_input(payload)
492
+ subagents = args_input.get("Subagents") or args_input.get("subagents")
493
+ roles = []
494
+ if isinstance(subagents, list):
495
+ for sub in subagents:
496
+ if isinstance(sub, dict):
497
+ r = _first(sub, ("Role", "role", "TypeName", "typeName", "type_name", "description"))
498
+ if r:
499
+ roles.append(str(r))
500
+ if not roles:
501
+ r = _first(args_input, ("Role", "role", "TypeName", "typeName", "description"))
502
+ if r:
503
+ roles.append(str(r))
504
+ run_id, root = resolve_run(payload, args.run_id, args.state_root, cwd)
505
+ if run_id:
506
+ for role_name in (roles or ["unknown"]):
507
+ record(
508
+ cwd,
509
+ root,
510
+ run_id,
511
+ {
512
+ "ts": now_iso(),
513
+ "run": run_id,
514
+ "event": event,
515
+ "data": {"role": role_name},
516
+ },
517
+ )
518
+
519
+
466
520
  def handle_compaction_event(payload: dict, args: argparse.Namespace) -> None:
467
521
  cwd = Path(args.cwd).resolve()
468
522
  run_id, root = resolve_run(payload if isinstance(payload, dict) else {}, args.run_id, args.state_root, cwd)
@@ -539,14 +593,26 @@ def main() -> int:
539
593
  if args.event in DECISION_EVENTS:
540
594
  decision = handle_decision_event(payload, args)
541
595
  if decision.allow:
596
+ name = tool_name(payload)
597
+ normalized_name = re.sub(r"[^a-z]", "", name)
598
+ if normalized_name == "invokesubagent":
599
+ record_invoke_subagent_event(payload, args, cwd, "subagent.started")
542
600
  return allow()
543
601
  message = f"deny: {decision.rule} {decision.path}"
544
602
  if args.json:
545
- print(json.dumps({"decision": "deny", "rule": decision.rule, "path": decision.path}, separators=(",", ":")))
603
+ print(json.dumps({"decision": "deny", "rule": decision.rule, "path": decision.path, "reason": message}, separators=(",", ":")))
604
+ return 0
546
605
  else:
547
606
  print(message)
548
- print(message, file=sys.stderr)
549
- return 2
607
+ print(message, file=sys.stderr)
608
+ return 2
609
+
610
+ if args.event == "PostToolUse":
611
+ name = tool_name(payload)
612
+ normalized_name = re.sub(r"[^a-z]", "", name)
613
+ if normalized_name == "invokesubagent":
614
+ record_invoke_subagent_event(payload, args, cwd, "subagent.stopped")
615
+ return allow()
550
616
 
551
617
  if args.event in SUBAGENT_START_EVENTS:
552
618
  handle_subagent_event(payload, args, "subagent.started")
@@ -29,14 +29,17 @@ EVENTS = {
29
29
  "policy.changed",
30
30
  "issue.done",
31
31
  "issue.blocked",
32
+ "issue.paused",
32
33
  "refutation",
33
34
  "review.finding",
35
+ "role.selected",
36
+ "role.changed",
34
37
  }
35
38
  RUN_EVENTS = {"run.started", "run.resumed", "run.cancelled", "run.finished"}
36
39
  ROUND_EVENTS = {"round.started", "round.finished"}
37
40
  PHASE_EVENTS = {"phase.started", "phase.finished"}
38
41
  SUBAGENT_EVENTS = {"subagent.started", "subagent.stopped"}
39
- ISSUE_EVENTS = {"issue.done", "issue.blocked", "refutation", "review.finding"}
42
+ ISSUE_EVENTS = {"issue.done", "issue.blocked", "issue.paused", "refutation", "review.finding", "role.selected", "role.changed"}
40
43
  FINISHED_EVENTS = {"run.cancelled", "run.finished"}
41
44
  UNIT_ID_RE = re.compile(r"^[0-9a-f]{12}$")
42
45
  RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
@@ -277,6 +280,29 @@ def validate_event(payload: object) -> dict:
277
280
  if not isinstance(data, dict) or "policyHash" not in data:
278
281
  raise EventError("policy.changed requires data.policyHash")
279
282
  require_string(data["policyHash"], "data.policyHash")
283
+ elif event == "issue.paused":
284
+ if "issue" not in payload:
285
+ raise EventError("issue.paused requires issue")
286
+ if not isinstance(data, dict) or not {"role", "reason"} <= set(data):
287
+ raise EventError("issue.paused requires data.role and data.reason")
288
+ require_string(data["role"], "data.role")
289
+ require_string(data["reason"], "data.reason")
290
+ elif event == "role.selected":
291
+ if "issue" not in payload:
292
+ raise EventError("role.selected requires issue")
293
+ if not isinstance(data, dict) or not {"role", "requested", "effective"} <= set(data):
294
+ raise EventError("role.selected requires data.role, data.requested, and data.effective")
295
+ require_string(data["role"], "data.role")
296
+ if not isinstance(data["requested"], dict) or not isinstance(data["effective"], dict):
297
+ raise EventError("data.requested and data.effective must be objects")
298
+ elif event == "role.changed":
299
+ if "issue" not in payload:
300
+ raise EventError("role.changed requires issue")
301
+ if not isinstance(data, dict) or not {"role", "requested", "effective"} <= set(data):
302
+ raise EventError("role.changed requires data.role, data.requested, and data.effective")
303
+ require_string(data["role"], "data.role")
304
+ if not isinstance(data["requested"], dict) or not isinstance(data["effective"], dict):
305
+ raise EventError("data.requested and data.effective must be objects")
280
306
  elif event in ISSUE_EVENTS and "issue" not in payload:
281
307
  raise EventError(f"{event} requires issue")
282
308
  return payload
@@ -339,6 +365,17 @@ def inflight(root: Path, unit: str) -> list[dict]:
339
365
  "phase": event["phase"],
340
366
  "worktree": event.get("data", {}).get("worktree"),
341
367
  }
368
+ elif event["event"] == "issue.paused":
369
+ if event["issue"] in active:
370
+ if event.get("data", {}).get("worktree"):
371
+ active[event["issue"]]["worktree"] = event["data"]["worktree"]
372
+ else:
373
+ active[event["issue"]] = {
374
+ "run": event["run"],
375
+ "issue": event["issue"],
376
+ "phase": event.get("phase") or "Critic",
377
+ "worktree": event.get("data", {}).get("worktree"),
378
+ }
342
379
  elif event["event"] == "phase.finished":
343
380
  active.pop(event["issue"], None)
344
381
  elif event["event"] in {"issue.done", "issue.blocked"}:
@@ -2,6 +2,8 @@
2
2
  """Conversational setup wizard for Gantry policy and hooks."""
3
3
  import argparse
4
4
  import json
5
+ import os
6
+ import shutil
5
7
  import sys
6
8
  from pathlib import Path
7
9
 
@@ -111,6 +113,43 @@ def main() -> None:
111
113
  indented_new_hooks = new_hooks_text.replace('\n', '\n' + ' ' * base_indent)
112
114
  settings_path.write_text(content[:start_idx] + indented_new_hooks + content[end_idx:], encoding="utf-8")
113
115
 
116
+ # Antigravity hook wiring: when Antigravity is detected, generate or merge .agents/hooks.json
117
+ if (repo_root / ".agents").exists() or shutil.which("agy") or os.environ.get("ANTIGRAVITY_PROJECT_DIR") or os.environ.get("GEMINI_CLI"):
118
+ ag_hook_frag_path = Path(__file__).resolve().parents[1] / "hooks" / "antigravity.hooks.json"
119
+ if ag_hook_frag_path.exists():
120
+ ag_hook_frag = json.loads(ag_hook_frag_path.read_text(encoding="utf-8"))
121
+ else:
122
+ guard_cmd = 'python3 ".agents/skills/gantry/scripts/guard.py" PreToolUse --json'
123
+ ag_hook_frag = {
124
+ "hooks": {
125
+ "PreToolUse": [
126
+ {
127
+ "matcher": "*",
128
+ "hooks": [
129
+ {
130
+ "type": "command",
131
+ "command": guard_cmd,
132
+ }
133
+ ]
134
+ }
135
+ ]
136
+ }
137
+ }
138
+ agents_dir = repo_root / ".agents"
139
+ agents_dir.mkdir(parents=True, exist_ok=True)
140
+ hooks_json_path = agents_dir / "hooks.json"
141
+ if hooks_json_path.exists():
142
+ try:
143
+ existing_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8"))
144
+ if not isinstance(existing_hooks, dict):
145
+ existing_hooks = {}
146
+ except Exception:
147
+ existing_hooks = {}
148
+ merged_hooks = merge_dicts(existing_hooks, ag_hook_frag)
149
+ hooks_json_path.write_text(json.dumps(merged_hooks, indent=2) + "\n", encoding="utf-8")
150
+ else:
151
+ hooks_json_path.write_text(json.dumps(ag_hook_frag, indent=2) + "\n", encoding="utf-8")
152
+
114
153
  agents_path = repo_root / "AGENTS.md"
115
154
  content = agents_path.read_text(encoding="utf-8") if agents_path.exists() else ""
116
155
  begin_marker = "<!-- gantry:begin -->"
@@ -16,15 +16,28 @@ You are the sole conversational writer of repository policy.
16
16
  - Enables recording by default.
17
17
  - Asks before denial hooks.
18
18
 
19
- 3. **Constraints**:
19
+ 3. **Caveman Lite Option**:
20
+ - Offer Caveman lite as a recommended, explicitly confirmed repository preference (`"caveman": true`).
21
+ - Explain conversational scope (messages and summaries only; specs, issues, code, contracts, and exact errors retain full detail), user-managed installation, fallback to normal behavior, and variable savings.
22
+ - If declined or skipped, resolve to disabled (`"caveman": false`).
23
+ - When confirmed and Caveman is not installed, guide user with host-harness installation command (`npx skills add caveman` for Claude Code; clone into `~/.gemini/config/skills/caveman` or `.agents/skills/caveman` for Antigravity; `.agents/skills/caveman` for OpenCode/Codex). Gantry runs no installer and changes no global agent configuration.
24
+ - After user reports installation, verify host-harness discovery and readability using `python3 .agents/skills/gantry/scripts/caveman.py check --harness <harness>`.
25
+
26
+ 4. **Role Execution Defaults & Antigravity**:
27
+ - Persist sparse repository role execution defaults under `execution.roles` in `.gantry/config.json`.
28
+ - Each role selection contains `harness`, `model` and optional `effort`.
29
+ - When Antigravity is detected (`.agents/` directory or `agy` CLI on PATH), `setup.py` generates or merges `.agents/hooks.json` to wire `PreToolUse` to `guard.py PreToolUse --json`.
30
+ - Unrelated repository policy, hook settings and Caveman opt-in are preserved during merges.
31
+
32
+ 5. **Constraints**:
20
33
  - Creates no engine, database, MCP service, or automatic cleanup.
21
34
  - All setup-generated policy, prompts, and marked content must be English.
22
35
 
23
- 4. **Applying the Policy**:
36
+ 6. **Applying the Policy**:
24
37
  Once the operator confirms the settings, construct the JSON configuration and pipe it to `setup.py`:
25
38
 
26
39
  ```bash
27
40
  python3 .agents/skills/gantry/scripts/setup.py --config '{...}'
28
41
  ```
29
42
 
30
- The `setup.py` script renders the full proposed `.gantry/config.json` before writing, supports merge, overwrite, and abort for an existing policy, handles idempotent merging of the Claude Code hook fragment into `.claude/settings.json`, and adds or replaces only the marked Gantry section in `AGENTS.md`. Do not modify these files directly.
43
+ The `setup.py` script renders the full proposed `.gantry/config.json` before writing, supports merge, overwrite, and abort for an existing policy, handles idempotent merging of the Claude Code hook fragment into `.claude/settings.json`, configures `.agents/hooks.json` when Antigravity is detected, and adds or replaces only the marked Gantry section in `AGENTS.md`. Do not modify these files directly.
package/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  **Turn an approved Spec into verified software deliveries inside your coding
4
4
  harness.**
5
5
 
6
+ [![CI](https://github.com/JulioBorges/gantry/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/JulioBorges/gantry/actions/workflows/ci.yml)
7
+ [![Release](https://github.com/JulioBorges/gantry/actions/workflows/release.yml/badge.svg)](https://github.com/JulioBorges/gantry/actions/workflows/release.yml)
8
+ [![npm version](https://img.shields.io/npm/v/%40julioborges%2Fgantry)](https://www.npmjs.com/package/@julioborges/gantry)
9
+ [![GitHub release](https://img.shields.io/github/v/release/JulioBorges/gantry)](https://github.com/JulioBorges/gantry/releases)
6
10
  [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
7
11
  [![Python: 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](#requirements)
8
12
  [![Agent Skills](https://img.shields.io/badge/Agent-Skills-orange.svg)](#installation)
@@ -46,9 +50,8 @@ claiming support for a particular integration. See [PRD §13](PRD.md#13-harness-
46
50
  for harness support and fixture requirements. A declared tier does not prove every harness version
47
51
  has been exercised.
48
52
 
49
- The npm installer is configured locally as `@julioborges/gantry` version `0.1.0`;
50
- publication is a separate release step. Use the GitHub installation below until
51
- an npm release is available.
53
+ The npm installer is published as `@julioborges/gantry`. The npm badge above
54
+ shows the published version; new releases require maintainer authorization.
52
55
 
53
56
  ## Requirements
54
57
 
@@ -83,7 +86,7 @@ Select all three skills and your harness in the installer:
83
86
  You can also copy or symlink the three directories into
84
87
  `<repo>/.agents/skills/` or `~/.agents/skills/`. The repository copy wins.
85
88
 
86
- Once the npm package is published, its bundled installer supports:
89
+ The npm package includes a bundled installer:
87
90
 
88
91
  ```bash
89
92
  npx @julioborges/gantry add
@@ -385,6 +388,11 @@ Use [GitHub Issues](https://github.com/JulioBorges/gantry/issues) to describe a
385
388
  reproducible problem or propose a change. Delivery Issues used by Gantry itself
386
389
  remain local Markdown artifacts.
387
390
 
391
+ All changes go through a PR; `main` requires passing CI and maintainer review.
392
+ Only [JulioBorges](https://github.com/JulioBorges) reviews and merges contributions.
393
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the fork workflow, required gates,
394
+ and the documented self-review exception for maintainer PRs.
395
+
388
396
  Before changing the pack, read `PRD.md`, `CONTEXT.md`, `docs/adr/`, and `AGENTS.md`.
389
397
  Keep documentation and generated artifacts in English. Explain behavior changes
390
398
  and include validation evidence in your pull request.
@@ -392,6 +400,9 @@ and include validation evidence in your pull request.
392
400
  Run the Python suite from the repository root:
393
401
 
394
402
  ```bash
403
+ python3 -m venv .venv
404
+ source .venv/bin/activate
405
+ python3 -m pip install -r requirements-dev.txt
395
406
  make test
396
407
  ```
397
408
 
@@ -409,28 +420,19 @@ verify with `roadmap.py check`.
409
420
 
410
421
  ### Publishing to npm
411
422
 
412
- The package is configured for public publication as `@julioborges/gantry`, initially version `0.1.0`.
413
- Only the installer, three skill directories, README and license are included; Python caches,
414
- project Issues, fixtures, local configuration and runtime state are excluded.
415
-
416
- ```bash
417
- npm ci
418
- npm run test:package
419
- npm pack --dry-run
420
- # After approving the release and authenticating with an account allowed to publish the scope:
421
- make release
422
- ```
423
-
424
- `make release` verifies the npm account before installing locked dependencies with `npm ci` and publishing the current version from
425
- `package.json` to npm. Update that version before publishing a subsequent release.
426
- `prepublishOnly` first checks `npm whoami` against npmjs.org and refuses publication unless the
427
- authenticated account is exactly `julioborges`. Authentication and network failures block publication.
428
- This applies to both `make release` and direct `npm publish`; npm's registry permissions remain the
429
- authority, and lifecycle hooks can be bypassed with npm options.
430
- After authorization, it runs the repository test suite and the tarball installation test once before publication.
431
- The tarball test executes `npx` and installs all three skills in a temporary project using the packed
432
- artifact. See [npm's package metadata documentation](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/)
433
- for the `bin`, `files` and `publishConfig` settings. No automatic publication is configured.
423
+ Releases use the manually authorized [Release workflow](.github/workflows/release.yml).
424
+ A version bump goes through a PR, then JulioBorges pushes a tag such as `v0.1.1`
425
+ for the merged commit and approves the `npm-release` environment. The workflow
426
+ derives the version from the tag and requires matching package and lockfile versions.
427
+ The workflow reruns gates, publishes the verified tarball using npm OIDC with
428
+ provenance, verifies registry integrity, and creates the GitHub Release for the immutable tag. Merging a PR does not automatically publish.
429
+
430
+ See the [maintainer release runbook](docs/maintainers/releases.md) for versioning,
431
+ authorization, npm trusted publisher setup, and recovery after partial failures.
432
+ The package includes only the installer, three skill directories, README and
433
+ license; project Issues, fixtures, caches, local configuration and runtime state
434
+ are excluded. Local `make release` retains its npm account check, but GitHub
435
+ Actions is the normal publication path.
434
436
 
435
437
  ## License
436
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@julioborges/gantry",
3
- "version": "0.1.0",
3
+ "version": "1.0.3",
4
4
  "description": "Install the Gantry harness-neutral agentic SDLC skill pack.",
5
5
  "type": "commonjs",
6
6
  "license": "Apache-2.0",
@@ -10,9 +10,20 @@
10
10
  },
11
11
  "homepage": "https://github.com/JulioBorges/gantry#readme",
12
12
  "bugs": "https://github.com/JulioBorges/gantry/issues",
13
- "keywords": ["agent-skills", "gantry", "sdlc", "claude-code", "codex", "opencode"],
14
- "engines": {"node": ">=22.20.0"},
15
- "bin": {"gantry": "bin/gantry.mjs"},
13
+ "keywords": [
14
+ "agent-skills",
15
+ "gantry",
16
+ "sdlc",
17
+ "claude-code",
18
+ "codex",
19
+ "opencode"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.20.0"
23
+ },
24
+ "bin": {
25
+ "gantry": "bin/gantry.mjs"
26
+ },
16
27
  "files": [
17
28
  "bin/gantry.mjs",
18
29
  "scripts/ensure-npm-author.mjs",
@@ -27,10 +38,15 @@
27
38
  ],
28
39
  "scripts": {
29
40
  "test": "make test",
30
- "test:package": "node --test tests/npm-package.test.mjs tests/npm-author.test.mjs",
41
+ "test:package": "node --test tests/npm-package.test.mjs tests/npm-author.test.mjs tests/github-release.test.mjs",
31
42
  "pack:check": "npm run test:package",
32
43
  "prepublishOnly": "node scripts/ensure-npm-author.mjs && npm test && npm run test:package"
33
44
  },
34
- "dependencies": {"skills": "1.5.26"},
35
- "publishConfig": {"access": "public", "registry": "https://registry.npmjs.org/"}
45
+ "dependencies": {
46
+ "skills": "1.5.26"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
51
+ }
36
52
  }