@snappedly-tools/shipyard 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +56 -8
  2. package/dist/index.js +27 -9
  3. package/dist/index.js.map +1 -1
  4. package/dist/main.js +51 -15
  5. package/dist/main.js.map +1 -1
  6. package/dist/templates/parallel-planner/block-scope.sh +160 -0
  7. package/dist/templates/parallel-planner/conflict-prompt.md +22 -0
  8. package/dist/templates/parallel-planner/handoff.sh +175 -0
  9. package/dist/templates/parallel-planner/implement-prompt.md +10 -56
  10. package/dist/templates/parallel-planner/main.mts +434 -188
  11. package/dist/templates/parallel-planner/merge-prompt.md +9 -21
  12. package/dist/templates/parallel-planner/plan-prompt.md +5 -31
  13. package/dist/templates/parallel-planner/select-issues.mjs +788 -0
  14. package/dist/templates/parallel-planner/setup.sh +51 -0
  15. package/dist/templates/parallel-planner/spec-wave-prompt.md +16 -0
  16. package/dist/templates/parallel-planner/template.json +1 -1
  17. package/dist/templates/parallel-planner/triage-prompt.md +5 -0
  18. package/dist/templates/parallel-planner/verify-triage.sh +19 -0
  19. package/dist/templates/parallel-planner-with-review/block-scope.sh +160 -0
  20. package/dist/templates/parallel-planner-with-review/conflict-prompt.md +22 -0
  21. package/dist/templates/parallel-planner-with-review/handoff.sh +175 -0
  22. package/dist/templates/parallel-planner-with-review/implement-prompt.md +10 -56
  23. package/dist/templates/parallel-planner-with-review/main.mts +473 -205
  24. package/dist/templates/parallel-planner-with-review/merge-prompt.md +9 -21
  25. package/dist/templates/parallel-planner-with-review/plan-prompt.md +5 -31
  26. package/dist/templates/parallel-planner-with-review/review-prompt.md +11 -49
  27. package/dist/templates/parallel-planner-with-review/select-issues.mjs +788 -0
  28. package/dist/templates/parallel-planner-with-review/setup.sh +51 -0
  29. package/dist/templates/parallel-planner-with-review/spec-wave-prompt.md +16 -0
  30. package/dist/templates/parallel-planner-with-review/template.json +1 -1
  31. package/dist/templates/parallel-planner-with-review/triage-prompt.md +5 -0
  32. package/dist/templates/parallel-planner-with-review/verify-triage.sh +19 -0
  33. package/dist/templates/sequential-reviewer/block-scope.sh +160 -0
  34. package/dist/templates/sequential-reviewer/handoff.sh +175 -0
  35. package/dist/templates/sequential-reviewer/implement-prompt.md +12 -46
  36. package/dist/templates/sequential-reviewer/main.mts +199 -103
  37. package/dist/templates/sequential-reviewer/review-prompt.md +11 -49
  38. package/dist/templates/sequential-reviewer/select-issues.mjs +788 -0
  39. package/dist/templates/sequential-reviewer/setup.sh +51 -0
  40. package/dist/templates/sequential-reviewer/template.json +1 -1
  41. package/dist/templates/sequential-reviewer/triage-prompt.md +5 -0
  42. package/dist/templates/sequential-reviewer/verify-triage.sh +19 -0
  43. package/dist/templates/shared/block-scope.sh +160 -0
  44. package/dist/templates/shared/handoff.sh +175 -0
  45. package/dist/templates/shared/select-issues.mjs +788 -0
  46. package/dist/templates/shared/setup.sh +51 -0
  47. package/dist/templates/shared/triage-prompt.md +5 -0
  48. package/dist/templates/shared/verify-triage.sh +19 -0
  49. package/dist/templates/simple-loop/block-scope.sh +160 -0
  50. package/dist/templates/simple-loop/handoff.sh +175 -0
  51. package/dist/templates/simple-loop/main.mts +190 -45
  52. package/dist/templates/simple-loop/prompt.md +12 -46
  53. package/dist/templates/simple-loop/select-issues.mjs +788 -0
  54. package/dist/templates/simple-loop/setup.sh +51 -0
  55. package/dist/templates/simple-loop/template.json +1 -1
  56. package/dist/templates/simple-loop/triage-prompt.md +5 -0
  57. package/dist/templates/simple-loop/verify-triage.sh +19 -0
  58. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ -n ${GH_REPO:-} ]]; then
5
+ [[ "$GH_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid GH_REPO" >&2; exit 1; }
6
+ git remote set-url origin "https://github.com/$GH_REPO.git"
7
+ fi
8
+
9
+ # Install for this sandbox's checkout. Host node_modules can contain binaries
10
+ # for a different OS and cannot cover dependencies added by the agent later.
11
+ if [[ -f package.json ]]; then
12
+ manager=$(node -e 'try { const p = require("./package.json").packageManager; process.stdout.write(p ? p.split("@")[0] : "") } catch {}')
13
+ if [[ -z "$manager" ]]; then
14
+ if [[ -f bun.lock || -f bun.lockb ]]; then manager=bun
15
+ elif [[ -f pnpm-lock.yaml ]]; then manager=pnpm
16
+ elif [[ -f yarn.lock ]]; then manager=yarn
17
+ else manager=npm; fi
18
+ fi
19
+ case "$manager" in
20
+ npm) npm install ;;
21
+ pnpm) corepack pnpm install ;;
22
+ yarn) corepack yarn install ;;
23
+ bun)
24
+ if ! command -v bun >/dev/null; then curl -fsSL https://bun.com/install | bash; export PATH="$HOME/.bun/bin:$PATH"; fi
25
+ bun install ;;
26
+ *) echo "Unsupported package manager: $manager" >&2; exit 1 ;;
27
+ esac
28
+ fi
29
+
30
+ source_dir=$(mktemp -d)
31
+ trap 'rm -rf "$source_dir"' EXIT
32
+ if ! git clone --depth 1 https://github.com/snappedly/skills.git "$source_dir/repo"; then
33
+ echo "Could not install Snappedly skills; agent work has not started." >&2
34
+ exit 1
35
+ fi
36
+
37
+ mkdir -p "$HOME/.agents/skills" "$HOME/.claude/skills"
38
+ while IFS= read -r -d '' skill_file; do
39
+ skill_dir=${skill_file%/SKILL.md}
40
+ skill_name=${skill_dir##*/}
41
+ rm -rf "$HOME/.agents/skills/$skill_name" "$HOME/.claude/skills/$skill_name"
42
+ cp -R "$skill_dir" "$HOME/.agents/skills/$skill_name"
43
+ ln -sfn "$HOME/.agents/skills/$skill_name" "$HOME/.claude/skills/$skill_name"
44
+ done < <(find "$source_dir/repo/skills" -name SKILL.md -print0)
45
+
46
+ for skill in triage implement implement-spec code-cleanup code-review tdd; do
47
+ if [[ ! -s "$HOME/.agents/skills/$skill/SKILL.md" || -z $(find "$source_dir/repo/skills" -path "*/$skill/SKILL.md" -print -quit) ]]; then
48
+ echo "Snappedly skill installation incomplete: $skill" >&2
49
+ exit 1
50
+ fi
51
+ done
@@ -0,0 +1,5 @@
1
+ Triage GitHub issue #{{TASK_ID}} before Shipyard implementation.
2
+
3
+ Follow `/triage` and the repository's `docs/agents/triage.md` label mapping. Read the full issue and comments, verify its claim against the codebase, then apply the appropriate category and state label and post the brief or triage notes required by the skill. The maintainer has authorized Shipyard to apply an evidence-based recommendation during this unattended intake. If a decision requires maintainer judgment, use `needs-triage` and record the question. Do not implement the issue or start a PR in this phase.
4
+
5
+ Report the applied state and evidence. Shipyard will independently re-read GitHub labels and implement only an open issue carrying both `shipyard` and the sole triage state `ready-for-agent`.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ issue=${1:?issue number required}
5
+ repo=${2:?GitHub repository required}
6
+ [[ "$issue" =~ ^[0-9]+$ ]] || { echo "Invalid issue number" >&2; exit 1; }
7
+ [[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid GitHub repository" >&2; exit 1; }
8
+
9
+ gh issue view "$issue" --repo "$repo" --json state,labels |
10
+ node -e '
11
+ const issue = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
12
+ const labels = new Set(issue.labels.map((label) => label.name));
13
+ const states = ["needs-triage", "needs-info", "ready-for-agent", "ready-for-human", "wontfix"];
14
+ if (issue.state !== "OPEN" || !labels.has("shipyard") ||
15
+ states.filter((state) => labels.has(state)).join() !== "ready-for-agent") {
16
+ console.error("Issue cannot be implemented: requires an open issue with shipyard and only ready-for-agent as its triage state");
17
+ process.exit(1);
18
+ }
19
+ '
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ root=${1:?scope root required}
5
+ failed=${2:?failed issue required}
6
+ repo=${3:?GitHub repository required}
7
+ scope=${4:?scope issue numbers required}
8
+ branch=${5:-shipyard/issue-$root}
9
+ [[ "$root" =~ ^[0-9]+$ && "$failed" =~ ^[0-9]+$ ]] || { echo "Invalid issue number" >&2; exit 1; }
10
+ [[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid GitHub repository" >&2; exit 1; }
11
+ [[ "$scope" =~ ^[0-9]+(,[0-9]+)*$ && ( "$scope" == "$root" || "$scope" == "$root,"* ) && ",$scope," == *",$failed,"* ]] || {
12
+ echo "Invalid blocked issue scope" >&2; exit 1;
13
+ }
14
+ [[ "$branch" == "shipyard/issue-$root" || "$branch" == "shipyard/spec-$root" ]] || { echo "Invalid scope branch" >&2; exit 1; }
15
+ reason=$(cat)
16
+ [[ -n "$reason" ]] || { echo "Missing failure reason" >&2; exit 1; }
17
+ reason_hash=$(printf '%s' "$reason" | git hash-object --stdin)
18
+ pending_dir=$(git rev-parse --git-path shipyard-pending)
19
+ mkdir -p "$pending_dir"
20
+ umask 077
21
+ pending_file="$pending_dir/$root-$failed.pending"
22
+ trap 'rm -f "${pending_tmp:-}"' EXIT
23
+ confirmed=""
24
+ if [[ -f "$pending_file" ]]; then
25
+ IFS=$'\t' read -r old_root old_failed old_repo old_scope old_branch old_confirmed < "$pending_file"
26
+ if [[ "$old_root" == "$root" && "$old_failed" == "$failed" && "$old_repo" == "$repo" && "$old_scope" == "$scope" && "$old_branch" == "$branch" ]]; then
27
+ confirmed=${old_confirmed:-}
28
+ fi
29
+ fi
30
+ write_pending() {
31
+ pending_tmp=$(mktemp "$pending_file.XXXXXX")
32
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n%s' "$root" "$failed" "$repo" "$scope" "$branch" "$confirmed" "$reason" > "$pending_tmp"
33
+ mv "$pending_tmp" "$pending_file"
34
+ }
35
+ confirm_blocked() {
36
+ if [[ ",$confirmed," != *",$1,"* ]]; then
37
+ confirmed=${confirmed:+$confirmed,}$1
38
+ write_pending
39
+ fi
40
+ }
41
+ write_pending
42
+
43
+ remove_issue_status() {
44
+ local labels
45
+ if gh issue edit "$1" --repo "$repo" --remove-label "$2"; then return 0; fi
46
+ labels=$(gh issue view "$1" --repo "$repo" --json labels --jq '.labels[].name') || return 75
47
+ if grep -Fxq "$2" <<< "$labels"; then
48
+ echo "Could not remove $2 from issue #$1" >&2
49
+ return 75
50
+ fi
51
+ }
52
+ remove_pr_status() {
53
+ local labels
54
+ if gh pr edit "$1" --repo "$repo" --remove-label "$2"; then return 0; fi
55
+ labels=$(gh pr view "$1" --repo "$repo" --json labels --jq '.labels[].name') || return 75
56
+ if grep -Fxq "$2" <<< "$labels"; then
57
+ echo "Could not remove $2 from PR #$1" >&2
58
+ return 75
59
+ fi
60
+ }
61
+ comment_once() {
62
+ local comments
63
+ if comments=$(gh issue view "$1" --repo "$repo" --json comments --jq '.comments[].body'); then
64
+ if grep -Fq "$3" <<< "$comments"; then return 0; fi
65
+ fi
66
+ gh issue comment "$1" --repo "$repo" --body "$2"
67
+ }
68
+
69
+ gh label create shipyard:blocked --repo "$repo" --color B60205 --description 'Shipyard work needs intervention' --force
70
+ status_synced=true
71
+ IFS=',' read -ra affected_ids <<< "$scope"
72
+ if [[ "$branch" == "shipyard/spec-$root" ]]; then
73
+ for affected_id in "${affected_ids[@]:1}"; do
74
+ gh issue edit "$affected_id" --repo "$repo" --add-label shipyard:blocked
75
+ confirm_blocked "$affected_id"
76
+ done
77
+ if ! gh issue edit "$root" --repo "$repo" --add-label shipyard:blocked; then
78
+ echo "Warning: could not update spec #$root status" >&2
79
+ status_synced=false
80
+ fi
81
+ else
82
+ gh issue edit "$root" --repo "$repo" --add-label shipyard:blocked
83
+ confirm_blocked "$root"
84
+ fi
85
+
86
+ status=0
87
+ marker="shipyard:blocked:$root:$failed:$reason_hash"
88
+ comment_once "$failed" "Shipyard could not complete this issue. Reason: $reason
89
+
90
+ Work was not handed off. To retry, resolve the problem, remove shipyard:blocked, and add shipyard to the affected ticket.
91
+
92
+ <!-- $marker:failed -->" "$marker:failed" || status=75
93
+ if [[ "$root" != "$failed" ]]; then
94
+ comment_once "$root" "Shipyard paused this spec because linked ticket #$failed failed. Reason: $reason
95
+
96
+ Work was not handed off. To retry, resolve the problem, remove shipyard:blocked, and add shipyard to each unfinished ticket.
97
+
98
+ <!-- $marker:parent -->" "$marker:parent" || status=75
99
+ fi
100
+ if [[ "$branch" == "shipyard/spec-$root" && "$failed" == "$root" ]]; then
101
+ for affected_id in "${affected_ids[@]:1}"; do
102
+ comment_once "$affected_id" "Shipyard could not complete this ticket during the spec attempt. Reason: $reason
103
+
104
+ Work was not handed off. To retry, resolve the problem, remove shipyard:blocked, and add shipyard to the affected ticket.
105
+
106
+ <!-- $marker:ticket:$affected_id -->" "$marker:ticket:$affected_id" || status=75
107
+ done
108
+ fi
109
+
110
+ if [[ "$branch" == "shipyard/spec-$root" ]]; then
111
+ if ! remove_issue_status "$root" shipyard:outstanding-tasks; then
112
+ echo "Warning: could not clear spec status" >&2
113
+ status_synced=false
114
+ fi
115
+ if ! remove_issue_status "$root" shipyard:complete; then
116
+ echo "Warning: could not clear spec status" >&2
117
+ status_synced=false
118
+ fi
119
+ else
120
+ remove_issue_status "$root" shipyard:outstanding-tasks || status=75
121
+ remove_issue_status "$root" shipyard:complete || status=75
122
+ fi
123
+ if [[ "$branch" == "shipyard/spec-$root" ]]; then
124
+ for affected_id in "${affected_ids[@]:1}"; do
125
+ remove_issue_status "$affected_id" shipyard:pending || status=75
126
+ done
127
+ else
128
+ remove_issue_status "$root" shipyard:pending || status=75
129
+ fi
130
+
131
+ if ! pr_number=$(gh pr list --repo "$repo" --head "$branch" --state open --json number --jq '.[0].number // empty'); then
132
+ echo "Warning: could not inspect PR status" >&2
133
+ status_synced=false
134
+ pr_number=""
135
+ fi
136
+ if [[ -n "$pr_number" ]]; then
137
+ if ! gh pr edit "$pr_number" --repo "$repo" --add-label shipyard:blocked; then
138
+ echo "Warning: could not update PR status" >&2
139
+ status_synced=false
140
+ fi
141
+ if ! remove_pr_status "$pr_number" shipyard:outstanding-tasks; then
142
+ echo "Warning: could not clear PR status" >&2
143
+ status_synced=false
144
+ fi
145
+ if ! remove_pr_status "$pr_number" shipyard:complete; then
146
+ echo "Warning: could not clear PR status" >&2
147
+ status_synced=false
148
+ fi
149
+ fi
150
+ if [[ "$status" -eq 0 && "$status_synced" == true ]]; then
151
+ IFS=',' read -ra scope_ids <<< "$scope"
152
+ for scope_id in "${scope_ids[@]}"; do
153
+ labels=$(gh issue view "$scope_id" --repo "$repo" --json labels --jq '.labels[].name')
154
+ if grep -Fxq shipyard <<< "$labels"; then
155
+ gh issue edit "$scope_id" --repo "$repo" --remove-label shipyard
156
+ fi
157
+ done
158
+ rm -f "$pending_file"
159
+ fi
160
+ exit "$status"
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ issue=${1:?issue number required}
5
+ branch=${2:?branch required}
6
+ base=${3:?target branch required}
7
+ repo=${4:?GitHub repository required}
8
+ scope=${5:-$issue}
9
+ outstanding=${6:--}
10
+ previously_completed=${7:--}
11
+ [[ "$issue" =~ ^[0-9]+$ && ( "$branch" == "shipyard/issue-$issue" || "$branch" == "shipyard/spec-$issue" ) ]] || {
12
+ echo "Invalid issue or branch for PR handoff" >&2; exit 1;
13
+ }
14
+ [[ "$base" =~ ^[A-Za-z0-9._/-]+$ ]] || {
15
+ echo "Invalid target branch for PR handoff" >&2; exit 1;
16
+ }
17
+
18
+ [[ "$scope" =~ ^[0-9]+(,[0-9]+)*$ && ( "$scope" == "$issue" || "$scope" == "$issue,"* ) ]] || { echo "Invalid PR scope" >&2; exit 1; }
19
+ [[ "$outstanding" == - || "$outstanding" =~ ^[0-9]+(,[0-9]+)*$ ]] || { echo "Invalid outstanding tickets" >&2; exit 1; }
20
+ [[ "$previously_completed" == - || "$previously_completed" =~ ^[0-9]+(,[0-9]+)*$ ]] || { echo "Invalid completed tickets" >&2; exit 1; }
21
+ evidence=$(cat)
22
+ [[ -n "$evidence" ]] || { echo "Missing verification evidence" >&2; exit 1; }
23
+ grep -Eiq '(^|[[:space:]])Checks:' <<< "$evidence" || { echo "Missing check evidence" >&2; exit 1; }
24
+ grep -Eiq '(^|[[:space:]])Review:' <<< "$evidence" || { echo "Missing review evidence" >&2; exit 1; }
25
+ [[ -z $(git status --porcelain) ]] || {
26
+ echo "Uncommitted changes prevent PR handoff" >&2; exit 1;
27
+ }
28
+ base_ref=$(git rev-parse --verify "refs/remotes/origin/$base^{commit}") || {
29
+ echo "Target branch is unavailable in the sandbox" >&2; exit 1;
30
+ }
31
+ branch_base=$base_ref
32
+ if ! git merge-base --is-ancestor "$base_ref" HEAD; then
33
+ branch_base=$(git merge-base "$base_ref" HEAD) || {
34
+ echo "PR branch has no common base with the target branch" >&2; exit 1;
35
+ }
36
+ fi
37
+ [[ -n $(git log "$branch_base"..HEAD --format=%H) ]] || {
38
+ echo "No verified commit to publish" >&2; exit 1;
39
+ }
40
+
41
+ [[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "Invalid GitHub repository" >&2; exit 1; }
42
+ remove_issue_label() {
43
+ local labels
44
+ if gh issue edit "$1" --repo "$repo" --remove-label "$2"; then return 0; fi
45
+ labels=$(gh issue view "$1" --repo "$repo" --json labels --jq '.labels[].name') || return 75
46
+ if grep -Fxq "$2" <<< "$labels"; then
47
+ echo "Could not remove $2 from issue #$1; retry handoff when GitHub is available" >&2
48
+ return 75
49
+ fi
50
+ }
51
+ remove_pr_label() {
52
+ local labels
53
+ if gh pr edit "$1" --repo "$repo" --remove-label "$2"; then return 0; fi
54
+ labels=$(gh pr view "$1" --repo "$repo" --json labels --jq '.labels[].name') || return 75
55
+ if grep -Fxq "$2" <<< "$labels"; then
56
+ echo "Could not remove $2 from PR #$1; retry handoff when GitHub is available" >&2
57
+ return 75
58
+ fi
59
+ }
60
+ title=$(gh issue view "$issue" --repo "$repo" --json title --jq .title)
61
+ body=$(mktemp)
62
+ trap 'rm -f "$body"' EXIT
63
+ links=""
64
+ implemented=""
65
+ IFS=',' read -ra scope_ids <<< "$scope"
66
+ for scope_id in "${scope_ids[@]}"; do links+="#$scope_id "; done
67
+ for ((i=1; i<${#scope_ids[@]}; i++)); do implemented+="#${scope_ids[i]} "; done
68
+ if [[ "$previously_completed" != - ]]; then
69
+ IFS=',' read -ra completed_ids <<< "$previously_completed"
70
+ for completed_id in "${completed_ids[@]}"; do links+="#$completed_id "; implemented+="#$completed_id "; done
71
+ fi
72
+ if [[ "$outstanding" != - ]]; then
73
+ IFS=',' read -ra outstanding_ids <<< "$outstanding"
74
+ for outstanding_id in "${outstanding_ids[@]}"; do links+="#$outstanding_id "; done
75
+ fi
76
+ printf 'Source issues: %s\nImplemented tickets: %s\n\n## Verification and review\n\n%s\n\nHuman review and merge required.\n\n<!-- shipyard:verified-handoff -->\n' "$links" "$implemented" "$evidence" > "$body"
77
+
78
+ # Keep Git credentials inside the disposable sandbox, not in the host worktree.
79
+ git remote set-url origin "https://github.com/$repo.git"
80
+ git -c credential.helper='!gh auth git-credential' push -u origin "HEAD:refs/heads/$branch"
81
+ number=$(gh pr list --repo "$repo" --head "$branch" --state open --json number --jq '.[0].number // empty')
82
+ if [[ -z "$number" ]]; then
83
+ gh pr create --repo "$repo" --head "$branch" --base "$base" --title "$title" --body-file "$body" --draft
84
+ number=$(gh pr list --repo "$repo" --head "$branch" --state open --json number --jq '.[0].number // empty')
85
+ fi
86
+ [[ -n "$number" ]] || { echo "PR publication did not return an open PR" >&2; exit 1; }
87
+ gh pr edit "$number" --repo "$repo" --body-file "$body"
88
+ draft=$(gh pr view "$number" --repo "$repo" --json isDraft --jq .isDraft)
89
+ url=$(gh pr view "$number" --repo "$repo" --json url --jq .url)
90
+ [[ -n "$url" ]] || { echo "PR publication did not return a URL" >&2; exit 1; }
91
+ if [[ "$draft" == true ]]; then gh pr ready "$number" --repo "$repo" || true; fi
92
+ if ! current_draft=$(gh pr view "$number" --repo "$repo" --json isDraft --jq .isDraft) ||
93
+ ! current_state=$(gh pr view "$number" --repo "$repo" --json state --jq .state); then
94
+ echo "Could not confirm PR readiness; retry publication when GitHub is available" >&2
95
+ exit 75
96
+ fi
97
+ if [[ "$current_draft" != false || "$current_state" != OPEN ]]; then
98
+ echo "PR did not become ready for human review" >&2
99
+ exit 1
100
+ fi
101
+ for ((i=1; i<${#scope_ids[@]}; i++)); do
102
+ if ! gh issue edit "${scope_ids[i]}" --repo "$repo" --add-label shipyard:complete; then
103
+ echo "Could not mark ticket #${scope_ids[i]} complete; retry issue completion when GitHub is available" >&2
104
+ exit 75
105
+ fi
106
+ remove_issue_label "${scope_ids[i]}" shipyard:blocked
107
+ remove_issue_label "${scope_ids[i]}" shipyard:pending
108
+ done
109
+ status_label=shipyard:complete
110
+ status_color=0E8A16
111
+ status_description='Shipyard work ready for human review'
112
+ if [[ "$outstanding" != - ]]; then
113
+ status_label=shipyard:outstanding-tasks
114
+ status_color=FBCA04
115
+ status_description='Spec has uncompleted tickets'
116
+ for outstanding_id in "${outstanding_ids[@]}"; do
117
+ labels=$(gh issue view "$outstanding_id" --repo "$repo" --json labels --jq '.labels[].name') || exit 75
118
+ if grep -Fxq shipyard:blocked <<< "$labels" && ! grep -Fxq shipyard:complete <<< "$labels"; then
119
+ status_label=shipyard:blocked
120
+ status_color=B60205
121
+ status_description='Shipyard work needs intervention'
122
+ break
123
+ fi
124
+ done
125
+ fi
126
+ status_synced=true
127
+ if ! gh label create "$status_label" --repo "$repo" --color "$status_color" --description "$status_description" --force; then
128
+ echo "Warning: could not create $status_label" >&2
129
+ status_synced=false
130
+ fi
131
+ if ! gh issue edit "$issue" --repo "$repo" --add-label "$status_label"; then
132
+ if [[ "$branch" == "shipyard/issue-$issue" ]]; then
133
+ echo "Could not mark ticket #$issue complete; retry issue completion when GitHub is available" >&2
134
+ exit 75
135
+ fi
136
+ echo "Warning: could not update spec #$issue status" >&2
137
+ status_synced=false
138
+ fi
139
+ for stale_label in shipyard:complete shipyard:outstanding-tasks shipyard:blocked; do
140
+ if [[ "$stale_label" != "$status_label" ]]; then
141
+ if ! remove_issue_label "$issue" "$stale_label"; then
142
+ if [[ "$branch" == "shipyard/issue-$issue" ]]; then exit 75; fi
143
+ echo "Warning: could not clear $stale_label from spec #$issue" >&2
144
+ status_synced=false
145
+ fi
146
+ fi
147
+ done
148
+ if [[ "$branch" == "shipyard/issue-$issue" ]]; then remove_issue_label "$issue" shipyard:pending; fi
149
+ if ! gh pr edit "$number" --repo "$repo" --add-label "$status_label"; then
150
+ echo "Warning: could not update PR #$number status" >&2
151
+ status_synced=false
152
+ fi
153
+ for stale_label in shipyard:complete shipyard:outstanding-tasks shipyard:blocked; do
154
+ if [[ "$stale_label" != "$status_label" ]]; then
155
+ if ! remove_pr_label "$number" "$stale_label"; then
156
+ echo "Warning: could not clear $stale_label from PR #$number" >&2
157
+ status_synced=false
158
+ fi
159
+ fi
160
+ done
161
+ if [[ "$status_synced" == true ]]; then
162
+ for scope_id in "${scope_ids[@]}"; do
163
+ if ! labels=$(gh issue view "$scope_id" --repo "$repo" --json labels --jq '.labels[].name'); then
164
+ echo "Warning: could not inspect activation on issue #$scope_id; the next invocation will retry cleanup" >&2
165
+ continue
166
+ fi
167
+ if grep -Fxq shipyard <<< "$labels"; then
168
+ gh issue edit "$scope_id" --repo "$repo" --remove-label shipyard ||
169
+ echo "Warning: could not remove activation from issue #$scope_id; the next invocation will retry cleanup" >&2
170
+ fi
171
+ done
172
+ else
173
+ echo "Warning: status labels need reconciliation; retaining ticket activation" >&2
174
+ fi
175
+ printf '%s\n' "$url"
@@ -1,51 +1,196 @@
1
- import { CODEX_MODELS, run, codex } from "@snappedly-tools/shipyard";
1
+ // Simple loop: select one activated issue scope per iteration, implement it,
2
+ // then hand its verified commit to a human through a pull request.
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import * as shipyard from "@snappedly-tools/shipyard";
2
6
  import { docker } from "@snappedly-tools/shipyard/sandboxes/docker";
3
7
 
4
- // Simple loop: an agent that picks open issues one by one and closes them.
5
- // Generated entrypoint: .shipyard/main.mts
6
- // Run this with: npx shipyard run
7
- // Or add to package.json scripts: "shipyard": "shipyard run"
8
-
9
- await run({
10
- // A name for this run, shown as a prefix in log output.
11
- name: "worker",
12
-
13
- // Sandbox provider — runs the agent inside an isolated container.
14
- sandbox: docker(),
15
-
16
- // The agent provider. The routine configured Codex model runs at max
17
- // reasoning by default. Switch to CODEX_MODELS.strong for more demanding
18
- // planning or review work.
19
- agent: codex(CODEX_MODELS.routine),
20
-
21
- // Path to the prompt file. Shell expressions inside are evaluated inside the
22
- // sandbox at the start of each iteration, so the agent always sees fresh data.
23
- promptFile: "./.shipyard/prompt.md",
8
+ if (process.loadEnvFile && existsSync(".shipyard/.env"))
9
+ process.loadEnvFile(".shipyard/.env");
10
+ const targetBranch = execFileSync("git", ["branch", "--show-current"], {
11
+ encoding: "utf8",
12
+ }).trim();
13
+ const repository = execFileSync(
14
+ "gh",
15
+ ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
16
+ { encoding: "utf8" },
17
+ ).trim();
18
+ if (
19
+ !/^[A-Za-z0-9._/-]+$/.test(targetBranch) ||
20
+ !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)
21
+ ) {
22
+ throw new Error("Invalid target branch or GitHub repository");
23
+ }
24
+ process.env.GH_REPO = repository;
25
+ const hooks = {
26
+ sandbox: {
27
+ onSandboxReady: [
28
+ { command: "timeout 300 bash .shipyard/setup.sh", timeoutMs: 300_000 },
29
+ ],
30
+ },
31
+ };
32
+ const closeClean = async (sandbox: {
33
+ close: () => Promise<{ preservedWorktreePath?: string }>;
34
+ }) => {
35
+ const { preservedWorktreePath } = await sandbox.close();
36
+ if (preservedWorktreePath)
37
+ throw new Error(`Sandbox has uncommitted work at ${preservedWorktreePath}`);
38
+ };
39
+ const verifyTriage = (ticketId: string) => {
40
+ try {
41
+ execFileSync("bash", [".shipyard/verify-triage.sh", ticketId, repository], {
42
+ encoding: "utf8",
43
+ });
44
+ } catch (error) {
45
+ const detail = (error as { stderr?: string | Buffer }).stderr
46
+ ?.toString()
47
+ .trim();
48
+ throw new Error(detail || `Could not verify triage for #${ticketId}`);
49
+ }
50
+ };
24
51
 
25
- // Maximum number of iterations (agent invocations) to run in a session.
26
- // Each iteration works on a single issue. Increase this to process more issues
27
- // per run, or set it to 1 for a single-shot mode.
28
- maxIterations: 3,
52
+ const blockScope = (
53
+ scope: { id: string; branch: string; tickets?: Array<{ id: string }> },
54
+ error: unknown,
55
+ ) => {
56
+ const reason = (error instanceof Error ? error.message : String(error)).slice(
57
+ 0,
58
+ 3000,
59
+ );
60
+ execFileSync(
61
+ "bash",
62
+ [
63
+ ".shipyard/block-scope.sh",
64
+ scope.id,
65
+ scope.id,
66
+ repository,
67
+ [scope.id, ...(scope.tickets ?? []).map((ticket) => ticket.id)].join(","),
68
+ scope.branch,
69
+ ],
70
+ { input: reason, encoding: "utf8" },
71
+ );
72
+ console.error(`Shipyard blocked issue #${scope.id}: ${reason}`);
73
+ };
29
74
 
30
- // Branch strategy — merge-to-head creates a temporary branch for the agent
31
- // to work on, then merges the result back to HEAD when the run completes.
32
- // This is required when using copyToWorktree, since head mode bind-mounts
33
- // the host directory directly (no worktree to copy into).
34
- branchStrategy: { type: "merge-to-head" },
75
+ for (let iteration = 0; iteration < 3; iteration++) {
76
+ const issues = JSON.parse(
77
+ execFileSync("node", [".shipyard/select-issues.mjs"], { encoding: "utf8" }),
78
+ ) as Array<{
79
+ id: string;
80
+ title: string;
81
+ branch: string;
82
+ kind: "standalone" | "spec";
83
+ body?: string;
84
+ tickets?: Array<{
85
+ id: string;
86
+ title: string;
87
+ body: string;
88
+ state: string;
89
+ blockedBy: Array<{ id: string; title: string; state: string }>;
90
+ }>;
91
+ completedTicketIds?: string[];
92
+ outstandingTicketIds?: string[];
93
+ }>;
94
+ const issue = issues[0];
95
+ if (!issue) break;
35
96
 
36
- // Copy node_modules from the host into the worktree before the sandbox
37
- // starts. This avoids a full npm install from scratch on every iteration.
38
- // The onSandboxReady hook still runs npm install as a safety net to handle
39
- // platform-specific binaries and any packages added since the last copy.
40
- copyToWorktree: ["node_modules"],
97
+ let handedOff = false;
98
+ let publicationUncertain = false;
99
+ try {
100
+ execFileSync("gh", [
101
+ "label",
102
+ "create",
103
+ "shipyard:pending",
104
+ "--repo",
105
+ repository,
106
+ "--color",
107
+ "1D76DB",
108
+ "--description",
109
+ "Shipyard is working on this ticket",
110
+ "--force",
111
+ ]);
112
+ for (const ticketId of issue.kind === "spec"
113
+ ? (issue.tickets ?? []).map((ticket) => ticket.id)
114
+ : [issue.id])
115
+ execFileSync("gh", [
116
+ "issue",
117
+ "edit",
118
+ ticketId,
119
+ "--repo",
120
+ repository,
121
+ "--add-label",
122
+ "shipyard:pending",
123
+ ]);
124
+ const sandbox = await shipyard.createSandbox({
125
+ branch: issue.branch,
126
+ sandbox: docker(),
127
+ hooks,
128
+ });
129
+ let evidence: string;
130
+ try {
131
+ for (const ticketId of issue.kind === "spec"
132
+ ? (issue.tickets ?? []).map((ticket) => ticket.id)
133
+ : [issue.id]) {
134
+ await sandbox.run({
135
+ name: `triage #${ticketId}`,
136
+ agent: shipyard.codex(shipyard.CODEX_MODELS.strong),
137
+ maxIterations: 1,
138
+ promptFile: "./.shipyard/triage-prompt.md",
139
+ promptArgs: { TASK_ID: ticketId },
140
+ });
141
+ verifyTriage(ticketId);
142
+ }
143
+ const result = await sandbox.run({
144
+ name: "implementer",
145
+ agent: shipyard.codex(shipyard.CODEX_MODELS.routine),
146
+ maxIterations: 1,
147
+ promptFile: "./.shipyard/prompt.md",
148
+ promptArgs: {
149
+ TASK_ID: issue.id,
150
+ ISSUE_TITLE: issue.title,
151
+ BRANCH: issue.branch,
152
+ SCOPE: JSON.stringify(issue),
153
+ SKILL: issue.kind === "spec" ? "/implement-spec" : "/implement",
154
+ },
155
+ });
156
+ const packet = [
157
+ ...result.stdout.matchAll(/<handoff>([\s\S]*?)<\/handoff>/g),
158
+ ]
159
+ .at(-1)?.[1]
160
+ ?.trim();
161
+ if (!result.completionSignal || !packet)
162
+ throw new Error(
163
+ `Issue #${issue.id} has no verified completion evidence: ${result.stdout.trim().slice(-1200)}`,
164
+ );
165
+ evidence = packet;
166
+ } finally {
167
+ await closeClean(sandbox);
168
+ }
41
169
 
42
- // Lifecycle hooks — commands grouped by where they run (host or sandbox).
43
- hooks: {
44
- sandbox: {
45
- // onSandboxReady runs once after the sandbox is initialised and the repo is
46
- // synced in, before the agent starts. Use it to install dependencies or run
47
- // any other setup steps your project needs.
48
- onSandboxReady: [{ command: "npm install" }],
49
- },
50
- },
51
- });
170
+ // Sync-out rewrites sandbox commits on the host. Publish from the synced
171
+ // branch so a later invocation can fast-forward the same PR.
172
+ const publication = await shipyard.createSandbox({
173
+ branch: issue.branch,
174
+ sandbox: docker(),
175
+ });
176
+ try {
177
+ publicationUncertain = true;
178
+ const handoff = await publication.exec(
179
+ `bash .shipyard/handoff.sh ${issue.id} ${issue.branch} ${targetBranch} ${repository} ${[issue.id, ...(issue.tickets ?? []).map((ticket) => ticket.id)].join(",")} ${issue.outstandingTicketIds?.join(",") || "-"} ${issue.completedTicketIds?.join(",") || "-"}`,
180
+ { stdin: evidence },
181
+ );
182
+ publicationUncertain = handoff.exitCode === 75;
183
+ if (handoff.exitCode !== 0)
184
+ throw new Error(
185
+ `PR handoff for #${issue.id} failed: ${handoff.stderr || handoff.stdout}`,
186
+ );
187
+ console.log(handoff.stdout.trim());
188
+ handedOff = true;
189
+ } finally {
190
+ await publication.close();
191
+ }
192
+ } catch (error) {
193
+ if (handedOff || publicationUncertain) throw error;
194
+ blockScope(issue, error);
195
+ }
196
+ }