@raquezha/norpiv 0.0.2
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/README.md +154 -0
- package/bin/norpiv-install.cjs +166 -0
- package/cleanup/SKILL.md +53 -0
- package/frame/SKILL.md +38 -0
- package/grill-with-docs/SKILL.md +43 -0
- package/implement/SKILL.md +132 -0
- package/implement/scripts/enforce-branch.sh +122 -0
- package/package.json +49 -0
- package/plan/SKILL.md +34 -0
- package/scripts/reposcry-bootstrap.sh +84 -0
- package/scripts/reposcry-refresh.sh +19 -0
- package/scripts/reposcry-task-context.sh +32 -0
- package/scripts/triage_helper.sh +401 -0
- package/scripts/validate_active_task.sh +328 -0
- package/sync/SKILL.md +131 -0
- package/sync/design-brief.md +39 -0
- package/sync/jira_smart_sync.sh +156 -0
- package/triage/SKILL.md +102 -0
- package/update-docs/SKILL.md +32 -0
- package/update-docs/references/doc-destination-map.md +31 -0
- package/verify/SKILL.md +32 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# validate_active_task.sh
|
|
4
|
+
# Lightweight, print-only validation of .workflow/active_task.json and related files.
|
|
5
|
+
# This script never mutates task state; it only reports inconsistencies and suggested fixes.
|
|
6
|
+
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
|
+
TRIAGE_HELPER="$SCRIPT_DIR/triage_helper.sh"
|
|
11
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
12
|
+
ATF="$REPO_ROOT/.workflow/active_task.json"
|
|
13
|
+
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || true)
|
|
14
|
+
WORK_MD=""
|
|
15
|
+
TASK_DIR=""
|
|
16
|
+
META=""
|
|
17
|
+
WARNINGS=0
|
|
18
|
+
ERRORS=0
|
|
19
|
+
|
|
20
|
+
warn() {
|
|
21
|
+
WARNINGS=$((WARNINGS + 1))
|
|
22
|
+
echo "WARN: $*"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
error() {
|
|
26
|
+
ERRORS=$((ERRORS + 1))
|
|
27
|
+
echo "ERROR: $*"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ok() {
|
|
31
|
+
echo "OK: $*"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
json_read() {
|
|
35
|
+
local file="$1"
|
|
36
|
+
local expr="$2"
|
|
37
|
+
if [[ ! -f "$file" ]]; then
|
|
38
|
+
echo ""
|
|
39
|
+
return
|
|
40
|
+
fi
|
|
41
|
+
python3 - "$file" "$expr" <<'PY'
|
|
42
|
+
import json
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
path = Path(sys.argv[1])
|
|
48
|
+
expr = sys.argv[2]
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(path.read_text())
|
|
51
|
+
except Exception:
|
|
52
|
+
print("")
|
|
53
|
+
raise SystemExit
|
|
54
|
+
|
|
55
|
+
# Supports the tiny subset used by this validator:
|
|
56
|
+
# .field and .field // .fallback // empty
|
|
57
|
+
for part in [p.strip() for p in expr.split("//")]:
|
|
58
|
+
if part == "empty":
|
|
59
|
+
continue
|
|
60
|
+
match = re.fullmatch(r"\.([A-Za-z_][A-Za-z0-9_]*)", part)
|
|
61
|
+
if not match or not isinstance(data, dict):
|
|
62
|
+
continue
|
|
63
|
+
value = data.get(match.group(1))
|
|
64
|
+
if value not in (None, ""):
|
|
65
|
+
print(value)
|
|
66
|
+
raise SystemExit
|
|
67
|
+
print("")
|
|
68
|
+
PY
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
json_pretty() {
|
|
72
|
+
local file="$1"
|
|
73
|
+
python3 -m json.tool "$file"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
count_section() {
|
|
77
|
+
local section="$1"
|
|
78
|
+
local file="$2"
|
|
79
|
+
grep -Ec "^(## )?\[$section\][[:space:]]*$" "$file" 2>/dev/null || true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
section_body_nonempty() {
|
|
83
|
+
local section="$1"
|
|
84
|
+
local file="$2"
|
|
85
|
+
python3 - "$file" "$section" <<'PY'
|
|
86
|
+
import re
|
|
87
|
+
import sys
|
|
88
|
+
from pathlib import Path
|
|
89
|
+
|
|
90
|
+
path = Path(sys.argv[1])
|
|
91
|
+
section = sys.argv[2]
|
|
92
|
+
text = path.read_text() if path.exists() else ""
|
|
93
|
+
lines = text.splitlines()
|
|
94
|
+
header = re.compile(rf"^(## )?\[{re.escape(section)}\]\s*$")
|
|
95
|
+
any_header = re.compile(r"^(## )?\[[A-Z0-9_-]+\]\s*$")
|
|
96
|
+
start = next((i for i, line in enumerate(lines) if header.match(line)), None)
|
|
97
|
+
if start is None:
|
|
98
|
+
print("no")
|
|
99
|
+
raise SystemExit
|
|
100
|
+
end = len(lines)
|
|
101
|
+
for i in range(start + 1, len(lines)):
|
|
102
|
+
if any_header.match(lines[i]):
|
|
103
|
+
end = i
|
|
104
|
+
break
|
|
105
|
+
body = "\n".join(lines[start + 1:end]).strip()
|
|
106
|
+
meaningful = [line.strip() for line in body.splitlines() if line.strip() and line.strip() not in {"-", "- [ ]"}]
|
|
107
|
+
print("yes" if meaningful else "no")
|
|
108
|
+
PY
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
has_plan_checkbox() {
|
|
112
|
+
local file="$1"
|
|
113
|
+
python3 - "$file" <<'PY'
|
|
114
|
+
import re
|
|
115
|
+
import sys
|
|
116
|
+
from pathlib import Path
|
|
117
|
+
|
|
118
|
+
path = Path(sys.argv[1])
|
|
119
|
+
text = path.read_text() if path.exists() else ""
|
|
120
|
+
lines = text.splitlines()
|
|
121
|
+
header = re.compile(r"^(## )?\[PLAN\]\s*$")
|
|
122
|
+
any_header = re.compile(r"^(## )?\[[A-Z0-9_-]+\]\s*$")
|
|
123
|
+
start = next((i for i, line in enumerate(lines) if header.match(line)), None)
|
|
124
|
+
if start is None:
|
|
125
|
+
print("no")
|
|
126
|
+
raise SystemExit
|
|
127
|
+
end = len(lines)
|
|
128
|
+
for i in range(start + 1, len(lines)):
|
|
129
|
+
if any_header.match(lines[i]):
|
|
130
|
+
end = i
|
|
131
|
+
break
|
|
132
|
+
body = "\n".join(lines[start + 1:end])
|
|
133
|
+
print("yes" if re.search(r"- \[[ xX]\]", body) else "no")
|
|
134
|
+
PY
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
print_json_field_report() {
|
|
138
|
+
local label="$1"
|
|
139
|
+
local file="$2"
|
|
140
|
+
shift 2
|
|
141
|
+
|
|
142
|
+
echo
|
|
143
|
+
echo "$label field check:"
|
|
144
|
+
for field in "$@"; do
|
|
145
|
+
local value
|
|
146
|
+
value=$(json_read "$file" ".$field")
|
|
147
|
+
if [[ -n "$value" ]]; then
|
|
148
|
+
ok "$field=$value"
|
|
149
|
+
else
|
|
150
|
+
warn "$label missing .$field"
|
|
151
|
+
fi
|
|
152
|
+
done
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if [[ ! -f "$ATF" ]]; then
|
|
156
|
+
error "No active_task.json found at $ATF"
|
|
157
|
+
echo "Suggested fixes (dry):"
|
|
158
|
+
echo " - Run: $TRIAGE_HELPER <source> <id>"
|
|
159
|
+
exit 1
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
if ! python3 -m json.tool "$ATF" >/dev/null 2>&1; then
|
|
163
|
+
error "active_task.json is not valid JSON: $ATF"
|
|
164
|
+
exit 1
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
echo "Active task file: $ATF"
|
|
168
|
+
json_pretty "$ATF" || true
|
|
169
|
+
|
|
170
|
+
TASK_PATH=$(json_read "$ATF" '.taskPath // .path')
|
|
171
|
+
TASK_SOURCE=$(json_read "$ATF" '.source')
|
|
172
|
+
TASK_ID=$(json_read "$ATF" '.sourceId // .id')
|
|
173
|
+
ACTIVE_TASK=$(json_read "$ATF" '.active_task')
|
|
174
|
+
POINTER_BRANCH=$(json_read "$ATF" '.branch')
|
|
175
|
+
|
|
176
|
+
print_json_field_report "active_task.json" "$ATF" active_task source id sourceId taskPath path branch
|
|
177
|
+
|
|
178
|
+
echo
|
|
179
|
+
if [[ -n "$TASK_PATH" ]]; then
|
|
180
|
+
if [[ "$TASK_PATH" = /* ]]; then
|
|
181
|
+
WORK_MD="$TASK_PATH/WORK.md"
|
|
182
|
+
else
|
|
183
|
+
WORK_MD="$REPO_ROOT/$TASK_PATH/WORK.md"
|
|
184
|
+
fi
|
|
185
|
+
echo "Using taskPath -> checking: $WORK_MD"
|
|
186
|
+
elif [[ -n "$TASK_ID" ]]; then
|
|
187
|
+
echo "No taskPath in active_task.json; attempting to derive from source+id"
|
|
188
|
+
if [[ -n "$TASK_SOURCE" ]]; then
|
|
189
|
+
TASK_FOLDER="$TASK_SOURCE-$TASK_ID"
|
|
190
|
+
else
|
|
191
|
+
TASK_FOLDER="$TASK_ID"
|
|
192
|
+
fi
|
|
193
|
+
WORK_MD="$REPO_ROOT/.workflow/tasks/$TASK_FOLDER/WORK.md"
|
|
194
|
+
echo "Derived path: $WORK_MD"
|
|
195
|
+
else
|
|
196
|
+
error "Neither taskPath/path nor sourceId/id present in active_task.json"
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
|
|
200
|
+
ok "WORK.md exists at $WORK_MD"
|
|
201
|
+
TASK_DIR=$(dirname "$WORK_MD")
|
|
202
|
+
META="$TASK_DIR/metadata.json"
|
|
203
|
+
else
|
|
204
|
+
error "WORK.md not found at ${WORK_MD:-<unknown>}"
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [[ -n "$ACTIVE_TASK" && -n "$TASK_DIR" ]]; then
|
|
208
|
+
ACTUAL_TASK_FOLDER=$(basename "$TASK_DIR")
|
|
209
|
+
if [[ "$ACTIVE_TASK" == "$ACTUAL_TASK_FOLDER" ]]; then
|
|
210
|
+
ok "active_task matches task folder ($ACTIVE_TASK)"
|
|
211
|
+
else
|
|
212
|
+
warn "active_task ($ACTIVE_TASK) differs from task folder ($ACTUAL_TASK_FOLDER)"
|
|
213
|
+
fi
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
if [[ -n "$CURRENT_BRANCH" && -n "$POINTER_BRANCH" ]]; then
|
|
217
|
+
if [[ "$CURRENT_BRANCH" == "$POINTER_BRANCH" ]]; then
|
|
218
|
+
ok "current branch matches active pointer ($CURRENT_BRANCH)"
|
|
219
|
+
else
|
|
220
|
+
warn "current branch ($CURRENT_BRANCH) differs from active pointer branch ($POINTER_BRANCH)"
|
|
221
|
+
fi
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
META_ID=""
|
|
225
|
+
META_SOURCE=""
|
|
226
|
+
META_TASK_FOLDER=""
|
|
227
|
+
META_STATUS=""
|
|
228
|
+
META_PHASE=""
|
|
229
|
+
META_BRANCH=""
|
|
230
|
+
|
|
231
|
+
if [[ -n "$META" ]]; then
|
|
232
|
+
if [[ -f "$META" ]]; then
|
|
233
|
+
if python3 -m json.tool "$META" >/dev/null 2>&1; then
|
|
234
|
+
echo
|
|
235
|
+
echo "Metadata file: $META"
|
|
236
|
+
json_pretty "$META" || true
|
|
237
|
+
print_json_field_report "metadata.json" "$META" id source taskFolder status phase branch createdAt updatedAt
|
|
238
|
+
|
|
239
|
+
META_ID=$(json_read "$META" '.id')
|
|
240
|
+
META_SOURCE=$(json_read "$META" '.source')
|
|
241
|
+
META_TASK_FOLDER=$(json_read "$META" '.taskFolder')
|
|
242
|
+
META_STATUS=$(json_read "$META" '.status')
|
|
243
|
+
META_PHASE=$(json_read "$META" '.phase')
|
|
244
|
+
META_BRANCH=$(json_read "$META" '.branch')
|
|
245
|
+
|
|
246
|
+
if [[ -n "$META_ID" && -n "$TASK_ID" && "$META_ID" != "$TASK_ID" ]]; then
|
|
247
|
+
warn "metadata.id ($META_ID) differs from active_task id/sourceId ($TASK_ID)"
|
|
248
|
+
fi
|
|
249
|
+
if [[ -n "$META_SOURCE" && -n "$TASK_SOURCE" && "$META_SOURCE" != "$TASK_SOURCE" ]]; then
|
|
250
|
+
warn "metadata.source ($META_SOURCE) differs from active_task.source ($TASK_SOURCE)"
|
|
251
|
+
fi
|
|
252
|
+
if [[ -n "$META_TASK_FOLDER" && -n "$TASK_DIR" && "$META_TASK_FOLDER" != "$(basename "$TASK_DIR")" ]]; then
|
|
253
|
+
warn "metadata.taskFolder ($META_TASK_FOLDER) differs from task folder ($(basename "$TASK_DIR"))"
|
|
254
|
+
fi
|
|
255
|
+
if [[ -n "$CURRENT_BRANCH" && -n "$META_BRANCH" && "$CURRENT_BRANCH" != "$META_BRANCH" ]]; then
|
|
256
|
+
warn "current branch ($CURRENT_BRANCH) differs from metadata.branch ($META_BRANCH)"
|
|
257
|
+
fi
|
|
258
|
+
else
|
|
259
|
+
error "metadata.json is not valid JSON: $META"
|
|
260
|
+
fi
|
|
261
|
+
else
|
|
262
|
+
warn "metadata.json not found at $META"
|
|
263
|
+
fi
|
|
264
|
+
fi
|
|
265
|
+
|
|
266
|
+
if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
|
|
267
|
+
echo
|
|
268
|
+
echo "WORK.md section health:"
|
|
269
|
+
for section in BRIEF GRILL PLAN LOG META; do
|
|
270
|
+
count=$(count_section "$section" "$WORK_MD")
|
|
271
|
+
case "$count" in
|
|
272
|
+
0) warn "missing [$section] section" ;;
|
|
273
|
+
1) ok "[$section] section present once" ;;
|
|
274
|
+
*) warn "duplicate [$section] sections found ($count)" ;;
|
|
275
|
+
esac
|
|
276
|
+
done
|
|
277
|
+
|
|
278
|
+
if [[ "$META_PHASE" =~ ^(planned|implementing|verifying|synced|closed)$ ]]; then
|
|
279
|
+
if [[ "$(has_plan_checkbox "$WORK_MD")" == "yes" ]]; then
|
|
280
|
+
ok "[PLAN] contains checkboxes for phase=$META_PHASE"
|
|
281
|
+
else
|
|
282
|
+
warn "phase=$META_PHASE but [PLAN] has no checkboxes"
|
|
283
|
+
fi
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
if [[ "$META_PHASE" == "triaged" && "$(section_body_nonempty "BRIEF" "$WORK_MD")" == "yes" ]]; then
|
|
287
|
+
warn "phase=triaged but [BRIEF] appears populated; consider updating phase metadata"
|
|
288
|
+
fi
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
if [[ "$META_STATUS" =~ ^(done|archived)$ ]]; then
|
|
292
|
+
warn "active pointer references a task with status=$META_STATUS; consider cleanup, archive handling, or reopening intentionally"
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
echo
|
|
296
|
+
echo "Suggested fixes (dry):"
|
|
297
|
+
if [[ -n "$WORK_MD" && ! -f "$WORK_MD" ]]; then
|
|
298
|
+
echo " - Re-run triage: $TRIAGE_HELPER <source> <id>"
|
|
299
|
+
echo " - Or update .workflow/active_task.json to include a correct taskPath or source+id"
|
|
300
|
+
fi
|
|
301
|
+
if [[ -n "$META" && ! -f "$META" ]]; then
|
|
302
|
+
echo " - Resume/backfill metadata: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} resume"
|
|
303
|
+
elif [[ -n "$META" && -f "$META" ]]; then
|
|
304
|
+
if [[ -z "$META_STATUS" || -z "$META_PHASE" || -z "$(json_read "$META" '.createdAt')" || -z "$(json_read "$META" '.updatedAt')" ]]; then
|
|
305
|
+
echo " - Backfill missing metadata fields: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} resume"
|
|
306
|
+
fi
|
|
307
|
+
fi
|
|
308
|
+
if [[ -n "$WORK_MD" && -f "$WORK_MD" ]]; then
|
|
309
|
+
for section in BRIEF GRILL PLAN LOG META; do
|
|
310
|
+
count=$(count_section "$section" "$WORK_MD")
|
|
311
|
+
if [[ "$count" -gt 1 ]]; then
|
|
312
|
+
echo " - Manually merge duplicate [$section] sections in $WORK_MD"
|
|
313
|
+
fi
|
|
314
|
+
done
|
|
315
|
+
fi
|
|
316
|
+
if [[ -n "$META_STATUS" && "$META_STATUS" =~ ^(done|archived)$ ]]; then
|
|
317
|
+
echo " - Choose intentionally: $TRIAGE_HELPER ${TASK_SOURCE:-local} ${TASK_ID:-task} reopen"
|
|
318
|
+
echo " - Or leave closed and run cleanup when appropriate."
|
|
319
|
+
fi
|
|
320
|
+
if [[ "$WARNINGS" -eq 0 && "$ERRORS" -eq 0 ]]; then
|
|
321
|
+
echo " - No fixes suggested."
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
echo
|
|
325
|
+
echo "Validation complete (print-only). warnings=$WARNINGS errors=$ERRORS"
|
|
326
|
+
if [[ "$ERRORS" -gt 0 ]]; then
|
|
327
|
+
exit 1
|
|
328
|
+
fi
|
package/sync/SKILL.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sync
|
|
3
|
+
description: Synchronizes local RPIV task state (WORK.md) to external trackers (Jira, GitHub, GitLab). Use this to publish progress, update implementation status, and maintain a durable audit trail between local development and remote project management tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: sync
|
|
7
|
+
|
|
8
|
+
Maintains consistency between local `.workflow` state and the remote source of truth using a single Pi-owned living status comment per task.
|
|
9
|
+
|
|
10
|
+
## Guardrails
|
|
11
|
+
- **Pre-flight**: Always read `.workflow/active_task.json` and the active `WORK.md` before executing.
|
|
12
|
+
- **Privacy**: NEVER sync secrets, environment variables, or private notes not intended for stakeholders.
|
|
13
|
+
- **Integrity**: Do not modify `[BRIEF]` or `[GRILL]` sections.
|
|
14
|
+
- **Idempotency**: If the remote Pi status already reflects the current local state, do not post or update.
|
|
15
|
+
- **Human safety**: NEVER edit human-authored comments. Only update comments/notes containing the Pi sync marker.
|
|
16
|
+
|
|
17
|
+
## Living status marker
|
|
18
|
+
Every sync body MUST include this marker at the end:
|
|
19
|
+
|
|
20
|
+
```md
|
|
21
|
+
<!-- pi-sync-marker -->
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Also include the human-readable signature:
|
|
25
|
+
|
|
26
|
+
```md
|
|
27
|
+
🤖 *Synced by pi (AI assistant) on behalf of the developer.*
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The marker identifies the mutable Pi-owned status comment. Human comments after the Pi status must not force new Pi comments.
|
|
31
|
+
|
|
32
|
+
## Decision Logic (Find / No-op / Update / Create)
|
|
33
|
+
To keep remote history clean, use this hierarchy for Jira, GitHub, and GitLab:
|
|
34
|
+
|
|
35
|
+
1. Render the new sync body from local `WORK.md`.
|
|
36
|
+
2. Fetch existing comments/notes for the remote issue, PR, or MR.
|
|
37
|
+
3. Search for the newest comment/note containing `<!-- pi-sync-marker -->`.
|
|
38
|
+
4. If a marker comment exists and normalized body is identical: **NO-OP**.
|
|
39
|
+
5. If a marker comment exists and body differs: **UPDATE** that marker comment/note.
|
|
40
|
+
6. If no marker comment exists: **CREATE** one new Pi status comment/note.
|
|
41
|
+
|
|
42
|
+
Do **not** use latest-comment ownership as the primary decision. Latest-comment-only logic causes infinite comment spam when humans reply after Pi.
|
|
43
|
+
|
|
44
|
+
## Workflow
|
|
45
|
+
|
|
46
|
+
### 1. Discovery & State Loading
|
|
47
|
+
- Identify the platform and ID from `.workflow/active_task.json`.
|
|
48
|
+
- Extract **Slices** from `[PLAN]`, **Status** from `[LOG]`, and **Artifacts** such as PR/MR links, commit hashes, and verification output.
|
|
49
|
+
|
|
50
|
+
### 2. Payload Preparation
|
|
51
|
+
Format the message for two audiences:
|
|
52
|
+
- **Stakeholders**: summarize outcome, current state, and next step.
|
|
53
|
+
- **Developers**: list vertical slices, commit/PR/MR links, and verification evidence.
|
|
54
|
+
- **Signature and marker**: always append both the signature and `<!-- pi-sync-marker -->`.
|
|
55
|
+
|
|
56
|
+
### 3. Execution
|
|
57
|
+
|
|
58
|
+
#### Jira
|
|
59
|
+
Use the helper so ADF parsing and marker search stay centralized:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cat body.md | <skill_location>/jira_smart_sync.sh <ISSUE_ID>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Behavior:
|
|
66
|
+
- fetch recent comments newest-first, default limit `50`
|
|
67
|
+
- override limit with `PI_SYNC_COMMENT_LIMIT=<n>` if needed
|
|
68
|
+
- find newest marker comment anywhere in the fetched window
|
|
69
|
+
- update marker comment by ID, no-op if identical, create only if no marker exists
|
|
70
|
+
|
|
71
|
+
#### GitHub Issues / PRs
|
|
72
|
+
Use the issue comments API. PR comments use issue comments for PR body discussion.
|
|
73
|
+
|
|
74
|
+
Check:
|
|
75
|
+
```bash
|
|
76
|
+
gh api repos/:owner/:repo/issues/<id>/comments --paginate \
|
|
77
|
+
--jq 'map(select(.body | contains("<!-- pi-sync-marker -->"))) | last'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Update:
|
|
81
|
+
```bash
|
|
82
|
+
gh api -X PATCH repos/:owner/:repo/issues/comments/<comment_id> \
|
|
83
|
+
-f body=@body.md
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Create:
|
|
87
|
+
```bash
|
|
88
|
+
gh issue comment <id> --body-file body.md
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Rules:
|
|
92
|
+
- update only a comment containing the marker
|
|
93
|
+
- no-op when normalized body is already current
|
|
94
|
+
- create only when no marker comment exists
|
|
95
|
+
|
|
96
|
+
#### GitLab Issues / MRs
|
|
97
|
+
Use notes API for issues or merge requests.
|
|
98
|
+
|
|
99
|
+
Check MR notes:
|
|
100
|
+
```bash
|
|
101
|
+
glab api projects/:id/merge_requests/<iid>/notes --paginate \
|
|
102
|
+
--jq 'map(select(.body | contains("<!-- pi-sync-marker -->"))) | last'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Update MR note:
|
|
106
|
+
```bash
|
|
107
|
+
glab api -X PUT projects/:id/merge_requests/<iid>/notes/<note_id> \
|
|
108
|
+
-f body=@body.md
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Create MR note:
|
|
112
|
+
```bash
|
|
113
|
+
glab mr note <iid> --message "$(cat body.md)"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Rules:
|
|
117
|
+
- update only a note containing the marker
|
|
118
|
+
- no-op when normalized body is already current
|
|
119
|
+
- create only when no marker note exists
|
|
120
|
+
|
|
121
|
+
### 4. Local Confirmation
|
|
122
|
+
- Append a timestamped sync record to `WORK.md` `[LOG]` with action: `no-op`, `updated`, or `created`.
|
|
123
|
+
- Do not edit `[BRIEF]` or `[GRILL]`.
|
|
124
|
+
|
|
125
|
+
## Output Contract
|
|
126
|
+
Return a concise summary:
|
|
127
|
+
- **Target**: platform and issue/PR/MR ID
|
|
128
|
+
- **Action**: no-op / updated existing status / created new status
|
|
129
|
+
- **Reason**: marker found, body identical, marker missing, etc.
|
|
130
|
+
- **Link**: remote comment/note URL if available
|
|
131
|
+
- **Next step**: review, verify, cleanup, or continue implementation
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Design Brief: Hardened Tracker Sync (Zero-Trust)
|
|
2
|
+
|
|
3
|
+
## Skill Type
|
|
4
|
+
Workflow skill with supporting scripts for high-reliability tracker interaction.
|
|
5
|
+
|
|
6
|
+
## Audience/Context
|
|
7
|
+
Developers using `pi` for RPIV or task-based implementation, reporting progress to Jira, GitHub, or GitLab.
|
|
8
|
+
|
|
9
|
+
## Trigger Conditions
|
|
10
|
+
- Running `/sync` on a task with source `jira`, `github`, or `gitlab`.
|
|
11
|
+
- Running `/verify` or manually requesting a tracker update.
|
|
12
|
+
|
|
13
|
+
## Inputs
|
|
14
|
+
- `WORK.md`: factual state for slices, commits, PR/MR links, and test results.
|
|
15
|
+
- Tracker comments/notes API: existing remote status state.
|
|
16
|
+
- `<!-- pi-sync-marker -->`: stable marker used to identify Pi-owned living status comments.
|
|
17
|
+
|
|
18
|
+
## Outputs
|
|
19
|
+
- Updated existing Pi marker comment/note when content changed.
|
|
20
|
+
- Created new Pi marker comment/note only when no marker exists.
|
|
21
|
+
- No action when the existing Pi marker comment/note already matches local state.
|
|
22
|
+
|
|
23
|
+
## Key Decisions & Heuristics
|
|
24
|
+
1. **Marker Over Latest Actor**: Search for the newest comment/note containing `<!-- pi-sync-marker -->`; do not rely on the absolute latest comment. Human replies after Pi must not create infinite Pi comments.
|
|
25
|
+
2. **Human Comment Safety**: Only marker-owned Pi comments are mutable. Never edit human-authored comments.
|
|
26
|
+
3. **Recursive ADF Parsing**: Jira Cloud uses Atlassian Document Format. The Jira helper must crawl JSON recursively to find marker/signature text.
|
|
27
|
+
4. **Identity Comparison**: Normalize whitespace before comparing remote and local bodies to avoid unnecessary updates.
|
|
28
|
+
5. **Shell Safety**: Use temporary files and `--body-file`/API body file equivalents where possible to avoid shell length/escaping issues.
|
|
29
|
+
6. **Bounded Search**: Jira helper defaults to the newest 50 comments via `PI_SYNC_COMMENT_LIMIT`; increase only when marker comments are older than the default window.
|
|
30
|
+
|
|
31
|
+
## File Structure
|
|
32
|
+
- `sync/SKILL.md`: operational instructions for Jira/GitHub/GitLab.
|
|
33
|
+
- `sync/jira_smart_sync.sh`: Jira helper with ADF-aware marker search.
|
|
34
|
+
|
|
35
|
+
## Success Metrics
|
|
36
|
+
- Zero duplicate Pi comments when progress is static.
|
|
37
|
+
- Zero new Pi comments when a human replies after an existing Pi status comment.
|
|
38
|
+
- Zero overwritten human comments.
|
|
39
|
+
- Reliable detection of Pi's living status comment even after Jira ADF conversion.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# jira_smart_sync.sh - Hardened, Zero-Trust Sync for ACLI
|
|
4
|
+
# Usage: cat body.txt | ./jira_smart_sync.sh <ISSUE_ID>
|
|
5
|
+
#
|
|
6
|
+
# Maintains one Pi-owned living status comment per Jira work item.
|
|
7
|
+
# It finds an existing marker comment anywhere in the fetched window and updates it.
|
|
8
|
+
# It creates a new comment only when no Pi marker comment exists.
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
ISSUE_ID=${1:-}
|
|
13
|
+
PI_SYNC_MARKER="<!-- pi-sync-marker -->"
|
|
14
|
+
PI_SIGNATURE="🤖 *Synced by pi (AI assistant) on behalf of the developer.*"
|
|
15
|
+
COMMENT_LIMIT=${PI_SYNC_COMMENT_LIMIT:-50}
|
|
16
|
+
TMP_BODY=$(mktemp)
|
|
17
|
+
TMP_JSON=$(mktemp)
|
|
18
|
+
|
|
19
|
+
cleanup() {
|
|
20
|
+
rm -f "$TMP_BODY" "$TMP_JSON"
|
|
21
|
+
}
|
|
22
|
+
trap cleanup EXIT
|
|
23
|
+
|
|
24
|
+
cat > "$TMP_BODY"
|
|
25
|
+
|
|
26
|
+
if [[ -z "$ISSUE_ID" ]]; then
|
|
27
|
+
echo "Usage: cat body.txt | $0 <ISSUE_ID>"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Fetch newest comments first. The marker search is not latest-comment-only;
|
|
32
|
+
# the limit only bounds API cost/noise. Increase PI_SYNC_COMMENT_LIMIT if needed.
|
|
33
|
+
acli jira workitem comment list --key "$ISSUE_ID" --json --limit "$COMMENT_LIMIT" --order "-created" > "$TMP_JSON"
|
|
34
|
+
|
|
35
|
+
export PI_SYNC_MARKER
|
|
36
|
+
export PI_SIGNATURE
|
|
37
|
+
export NEW_BODY_FILE="$TMP_BODY"
|
|
38
|
+
export COMMENTS_JSON_FILE="$TMP_JSON"
|
|
39
|
+
|
|
40
|
+
MATCH_DATA=$(python3 <<'PY'
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import sys
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
marker = os.environ.get("PI_SYNC_MARKER", "<!-- pi-sync-marker -->")
|
|
48
|
+
signature = os.environ.get("PI_SIGNATURE", "")
|
|
49
|
+
new_body_file = Path(os.environ["NEW_BODY_FILE"])
|
|
50
|
+
comments_json_file = Path(os.environ["COMMENTS_JSON_FILE"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_text(obj):
|
|
54
|
+
if obj is None:
|
|
55
|
+
return ""
|
|
56
|
+
if isinstance(obj, str):
|
|
57
|
+
return obj
|
|
58
|
+
if isinstance(obj, list):
|
|
59
|
+
return "\n".join(get_text(x) for x in obj)
|
|
60
|
+
if isinstance(obj, dict):
|
|
61
|
+
# Common flattened outputs first.
|
|
62
|
+
rendered = obj.get("renderedBody")
|
|
63
|
+
if isinstance(rendered, str):
|
|
64
|
+
return rendered
|
|
65
|
+
body = obj.get("body")
|
|
66
|
+
if isinstance(body, str):
|
|
67
|
+
return body
|
|
68
|
+
# Atlassian Document Format text nodes.
|
|
69
|
+
if obj.get("type") == "text":
|
|
70
|
+
return obj.get("text", "")
|
|
71
|
+
content = obj.get("content")
|
|
72
|
+
if content:
|
|
73
|
+
return get_text(content)
|
|
74
|
+
return "\n".join(get_text(v) for v in obj.values() if isinstance(v, (dict, list, str)))
|
|
75
|
+
return str(obj)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize(text):
|
|
79
|
+
# Jira/ACLI rendering can alter blank lines and trailing whitespace. Keep the
|
|
80
|
+
# comparison conservative: avoid duplicates when content is materially equal.
|
|
81
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
82
|
+
lines = [line.rstrip() for line in text.strip().split("\n")]
|
|
83
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def comment_id(comment):
|
|
87
|
+
for key in ("id", "commentId", "comment_id"):
|
|
88
|
+
value = comment.get(key) if isinstance(comment, dict) else None
|
|
89
|
+
if value is not None:
|
|
90
|
+
return str(value)
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unwrap_comments(data):
|
|
95
|
+
if isinstance(data, list):
|
|
96
|
+
return data
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
for key in ("comments", "values", "results", "data"):
|
|
99
|
+
value = data.get(key)
|
|
100
|
+
if isinstance(value, list):
|
|
101
|
+
return value
|
|
102
|
+
if "id" in data:
|
|
103
|
+
return [data]
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
new_text = new_body_file.read_text().strip()
|
|
108
|
+
data = json.loads(comments_json_file.read_text())
|
|
109
|
+
comments = unwrap_comments(data)
|
|
110
|
+
|
|
111
|
+
if not comments:
|
|
112
|
+
print("CREATE|no existing comments")
|
|
113
|
+
raise SystemExit(0)
|
|
114
|
+
|
|
115
|
+
# Comments are fetched newest first. Pick the newest Pi-owned marker comment,
|
|
116
|
+
# not necessarily the absolute latest comment. Human comments remain untouched.
|
|
117
|
+
for comment in comments:
|
|
118
|
+
text = get_text(comment.get("body", comment)) if isinstance(comment, dict) else get_text(comment)
|
|
119
|
+
owns_comment = marker in text or (signature and signature in text)
|
|
120
|
+
if not owns_comment:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
cid = comment_id(comment)
|
|
124
|
+
if not cid:
|
|
125
|
+
print("CREATE|marker found without id")
|
|
126
|
+
raise SystemExit(0)
|
|
127
|
+
|
|
128
|
+
if normalize(new_text) == normalize(text):
|
|
129
|
+
print(f"NOOP|{cid}")
|
|
130
|
+
else:
|
|
131
|
+
print(f"UPDATE|{cid}")
|
|
132
|
+
raise SystemExit(0)
|
|
133
|
+
|
|
134
|
+
print("CREATE|no pi marker comment")
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
# Fail-open to create: better to keep stakeholder visibility than silently drop sync.
|
|
137
|
+
print(f"CREATE|parse fallback: {exc}")
|
|
138
|
+
PY
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
ACTION_CODE=$(echo "$MATCH_DATA" | cut -d'|' -f1)
|
|
142
|
+
TARGET_ID=$(echo "$MATCH_DATA" | cut -d'|' -f2-)
|
|
143
|
+
|
|
144
|
+
case "$ACTION_CODE" in
|
|
145
|
+
NOOP)
|
|
146
|
+
echo "SYNC: Existing Pi status is already current (ID: $TARGET_ID)."
|
|
147
|
+
;;
|
|
148
|
+
UPDATE)
|
|
149
|
+
echo "SYNC: Updating existing Pi status (ID: $TARGET_ID)."
|
|
150
|
+
acli jira workitem comment update --key "$ISSUE_ID" --id "$TARGET_ID" --body-file "$TMP_BODY"
|
|
151
|
+
;;
|
|
152
|
+
*)
|
|
153
|
+
echo "SYNC: Creating Pi status comment ($TARGET_ID)."
|
|
154
|
+
acli jira workitem comment create --key "$ISSUE_ID" --body-file "$TMP_BODY"
|
|
155
|
+
;;
|
|
156
|
+
esac
|