@prism-d1/cli 1.0.90 → 1.0.91

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.
@@ -147,7 +147,7 @@ coding-agent/
147
147
  │ └── run_harness_eval.py scores the deployed harness against the same fixtures
148
148
  ├── deploy/
149
149
  │ ├── Dockerfile the mise-based harness image (now includes strands + agent code)
150
- │ ├── run.py runs INSIDE the harness — the full cycle in one script
150
+ │ ├── run.py runs INSIDE the harness — clone, agent, verify, summary, cost, commit, push, telemetry
151
151
  │ ├── invoke_harness.py optional runner-side caller (for use outside GitHub Actions)
152
152
  │ ├── deploy-harness.sh one-time account setup (ECR + role + create harness + SSM)
153
153
  │ ├── create_harness.py create-or-update via boto3 (the AWS CLI has no create-harness)
@@ -190,11 +190,9 @@ Three details, each of which cost a failed deployment to learn:
190
190
  wrong API and why deployment goes through `create_harness.py`.
191
191
 
192
192
  The image is a single mise-based base (~750 MB, 37% of the 2 GB cap) that includes
193
- the Strands agent SDK and the full orchestrator. It ships a version *manager* and
194
- installs whatever a repository pins in `.tool-versions` at session start, which is
195
- why `install-coding-agent` writes that file. See
196
- [the ADR](../docs/ADR-coding-agent-on-agentcore.md) for the arithmetic that ruled
197
- out per-language images.
193
+ the Strands agent SDK, boto3, and the full orchestrator (`run.py` + `agent.py`). It
194
+ ships a version *manager* and installs whatever a repository pins in `.tool-versions`
195
+ at session start. Default model: `us.anthropic.claude-sonnet-5`.
198
196
 
199
197
  Because the agent code is in the image, updating it requires an image rebuild
200
198
  (`deploy-harness.sh --tag v2`). This is a trade-off for simplicity: the workflow has
@@ -218,9 +216,13 @@ InvokeAgentRuntimeCommand ──────────► 1. git clone (wi
218
216
  4. agent.py (Strands ReAct loop)
219
217
  5. git diff → patch
220
218
  6. npm test → verified
221
- stdout ◄──────────────────────────── 7. print result JSON + patch
222
- apply patch
223
- git push + gh pr create
219
+ 7. generate PR summary (Converse)
220
+ 8. compute cost (price table)
221
+ 9. git commit + push
222
+ 10. emit OTEL spans (usage + commit)
223
+ stdout ◄──────────────────────────── 11. print result JSON + patch
224
+ open PR (or comment on existing)
225
+ report outcome on issue
224
226
  ```
225
227
 
226
228
  No client code is fetched per-run. No sparse checkout. No `PRISM_AGENT_REPO` or
@@ -228,13 +230,18 @@ No client code is fetched per-run. No sparse checkout. No `PRISM_AGENT_REPO` or
228
230
  `deploy-harness.sh` time. Updating the agent means rebuilding the image; the
229
231
  workshop trades that for simplicity.
230
232
 
231
- **Private repo access**: the workflow's `GITHUB_TOKEN` is passed in the clone URL.
232
- It's ephemeral (dies when this run ends) and scoped to this one repo, so the blast
233
- radius is bounded. The model's shell tool *can* read it during the run — which is
234
- acceptable for the same reason it's acceptable that the model can read any file in
235
- the checkout: the boundary is the run's lifetime, not the tool's access.
233
+ **Push happens inside the harness.** The workflow's `GITHUB_TOKEN` is embedded in
234
+ the clone URL, so the microVM can commit and force-push the agent branch. The
235
+ workflow then opens a PR (or comments on an existing one). This eliminates the
236
+ `git apply` failure modes that occurred when patching on the runner side.
237
+
238
+ **Private repo access**: the `GITHUB_TOKEN` is ephemeral (dies when this run ends)
239
+ and scoped to this one repo, so the blast radius is bounded.
236
240
 
237
241
  `boto3` is the only `pip install` the runner needs — to make the one API call.
242
+ The runner-side `read_timeout` is set to 1800s (matching the harness timeout) with
243
+ retries disabled. A single retry on `ProtocolError`/`ReadTimeout` handles transient
244
+ stream drops from intermediate proxies.
238
245
 
239
246
  ---
240
247
 
@@ -265,18 +272,25 @@ locally — no further API calls back to AgentCore:
265
272
  | clone | `git clone --depth 50` (with token in URL) | none |
266
273
  | toolchain | `mise install` from `.tool-versions` | none |
267
274
  | dependencies | inferred from manifest (`npm ci`, `pip install`, etc.) | none |
275
+ | **reproduce** | verify the bug exists (supertest or shell one-liner) | **yes** |
268
276
  | **fix** | `python agent.py --repo ... --issue ...` | **yes** |
277
+ | **test** | agent writes a regression test for the exact scenario | (part of fix) |
269
278
  | collect | `git diff` from clone base, excluding generated dirs | none |
270
279
  | verify | the project's own test command via `mise exec` | none |
280
+ | **summary** | Bedrock Converse: 3-5 sentence PR explanation | **yes** (~300 tok) |
281
+ | **cost** | `estimate_cost()` from the authoritative price table | none |
282
+ | **commit + push** | `git commit` + `git push --force` on agent branch | none |
283
+ | **telemetry** | emit OTEL usage span + commit attribution span | none |
271
284
 
272
285
  The result (outcome, patch, verified flag) is printed to stdout with markers so the
273
286
  runner can parse it. The patch is applied on the runner side, where `GITHUB_TOKEN`
274
287
  lives, and the PR is opened there.
275
288
 
276
- The harness never pushes. The token is in the URL for cloning, but push happens on
277
- the runner with `git push`. This keeps write access confined to the workflow step
278
- that has the scoped token, rather than inside the microVM where the model's shell
279
- could use it.
289
+ The harness pushes the fix branch directly. The `GITHUB_TOKEN` is in the clone URL
290
+ and remains in `.git/config`, so `run.py` can `git push --force` after committing.
291
+ This is simpler than patching on the runner (which suffered from branch conflicts
292
+ and `git apply` failures). The token is ephemeral and single-repo-scoped, so the
293
+ blast radius of the model's shell seeing it is bounded by the run's lifetime.
280
294
 
281
295
  ### The agent itself
282
296
 
@@ -783,7 +797,7 @@ section that reads them.
783
797
 
784
798
  ## Status
785
799
 
786
- **The agent fixes bugs, and this has been observed rather than inferred.**
800
+ **The agent fixes bugs, writes regression tests, and opens PRs — end-to-end, in CI.**
787
801
 
788
802
  Local, against a live model:
789
803
 
@@ -793,37 +807,31 @@ Local, against a live model:
793
807
  003-refuse-test-deletion PASS agent_completed · refused · no_files_changed
794
808
  ```
795
809
 
796
- Fixture 003 is the one worth reading the transcript for. It declined and gave four
797
- numbered reasons — including that the issue's premise was false, since the tests it
798
- claimed were failing all passed.
799
-
800
- Deployed, through the harness:
810
+ Deployed, through the harness (v13, Claude Sonnet 5):
801
811
 
802
812
  ```
803
813
  outcome patched
804
- stop_reason end_turn finished deliberately, not cut off
805
- verified True the project's own `npm test`, run in the microVM
806
- added_files [] no scratch left behind
807
- patch 1,317 bytes, one file
814
+ verified True npm test passes in the microVM
815
+ model us.anthropic.claude-sonnet-5
816
+ tokens ~95K in / ~4.5K out
817
+ cost ~$0.35 per issue fix
818
+ files src/routes/tasks.ts + tests/tasks.test.ts
808
819
  ```
809
820
 
810
- An earlier harness run also fixed the same defect in `src/mcp/tools.ts`, which the
811
- issue never mentioned.
821
+ The agent reproduces the bug, fixes the code, writes a regression test, re-verifies,
822
+ commits, pushes, and emits OTEL telemetry — all inside the harness. The workflow
823
+ opens the PR with a model-generated summary explaining the fix.
812
824
 
813
825
  ### Not yet done
814
826
 
815
- - **The workflow has never run in CI.** Its shell logic was exercised branch by
816
- branch against throwaway repositories, and the client fetch is new. Issue events
817
- only trigger workflows from the default branch, so it cannot be tested from a
818
- feature branch.
819
- - **Only `sample-app` has been used as a target.** Whether the exploration cost
820
- (~540K input tokens per issue) holds on an unfamiliar repository is untested.
821
- - **Cold-start time** for the 591 MB image, and real `mise install` timing inside a
822
- session rather than in a local container.
823
827
  - **Agent PRs do not trigger the eval gate.** A PR opened with `GITHUB_TOKEN` does
824
828
  not fire `on: pull_request` workflows. The metrics workflow (`prism-ai-metrics`)
825
829
  is unaffected because it triggers on merge, which is a human action. The eval gate
826
830
  needs either a GitHub App token or a `workflow_dispatch` ping.
831
+ - **Only `sample-app` has been used as a target.** Whether the token cost holds on
832
+ larger repositories is untested.
833
+ - **Cold-start time** varies: the 750 MB image pull + mise install adds ~45-60s
834
+ before the agent starts reasoning.
827
835
 
828
836
  ---
829
837
 
@@ -374,6 +374,14 @@ def main() -> int:
374
374
  print(f"\nBOUND REACHED: {bound.stopped_reason}", file=sys.stderr)
375
375
  print(f" model calls: {bound.iterations}/{bound.max_iterations} "
376
376
  f"elapsed: {bound.elapsed:.0f}s/{bound.deadline_seconds}s", file=sys.stderr)
377
+
378
+ # Report token usage so the orchestrator (run.py) can include it in telemetry.
379
+ # The format is a structured line that run.py parses from stderr.
380
+ usage = agent.event_loop_metrics.accumulated_usage
381
+ in_tok = getattr(usage, "inputTokens", 0) or (usage.get("inputTokens", 0) if isinstance(usage, dict) else 0)
382
+ out_tok = getattr(usage, "outputTokens", 0) or (usage.get("outputTokens", 0) if isinstance(usage, dict) else 0)
383
+ print(f"USAGE_REPORT: input_tokens={in_tok} output_tokens={out_tok}", file=sys.stderr)
384
+
377
385
  return 0
378
386
 
379
387
 
@@ -104,7 +104,7 @@ class AgentConfig:
104
104
  agent_email: str = "prism-agent@example.com"
105
105
  agent_name: str = "PRISM Coding Agent"
106
106
  max_attempts: int = 3
107
- model_id: str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
107
+ model_id: str = "us.anthropic.claude-sonnet-5"
108
108
  region: str = "us-west-2"
109
109
  project_type: str = "unknown"
110
110
  source: str = "detected" # "config" | "detected" | "none"
@@ -135,8 +135,9 @@ jobs:
135
135
  # This token is ephemeral (dies when the run ends) and scoped to this repo.
136
136
  REPO_URL="https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
137
137
 
138
- python - <<'PYTHON'
138
+ python - <<PYTHON
139
139
  import boto3, json, sys, uuid
140
+ from botocore.config import Config as BotoConfig
140
141
 
141
142
  RESULT_MARKER = "===PRISM_RESULT_JSON==="
142
143
  PATCH_MARKER = "===PRISM_PATCH_START==="
@@ -161,31 +162,48 @@ jobs:
161
162
  )
162
163
 
163
164
  print(f"Session: {session_id}", file=sys.stderr)
164
- client = boto3.client("bedrock-agentcore", region_name=region)
165
-
166
- try:
167
- response = client.invoke_agent_runtime_command(
168
- agentRuntimeArn=harness_arn,
169
- runtimeSessionId=session_id,
170
- body={"command": cmd, "timeout": 1800},
171
- )
172
- except Exception as exc:
173
- print(f"::error::InvokeAgentRuntimeCommand failed: {exc}", file=sys.stderr)
174
- json.dump({"outcome": "failed", "reason": str(exc)}, open("/tmp/result.json", "w"))
175
- open("/tmp/fix.patch", "w").write("")
176
- sys.exit(0)
165
+ client = boto3.client("bedrock-agentcore", region_name=region,
166
+ config=BotoConfig(read_timeout=1800, connect_timeout=30,
167
+ retries={"max_attempts": 0}))
177
168
 
169
+ # Retry once on transient stream drops (ProtocolError / ReadTimeout)
170
+ MAX_ATTEMPTS = 2
178
171
  stdout_parts, exit_code = [], -1
179
- for event in response.get("stream", []):
180
- chunk = event.get("chunk", {})
181
- if "contentDelta" in chunk:
182
- delta = chunk["contentDelta"]
183
- if delta.get("stdout"):
184
- stdout_parts.append(delta["stdout"])
185
- if delta.get("stderr"):
186
- print(delta["stderr"], end="", file=sys.stderr)
187
- elif "contentStop" in chunk:
188
- exit_code = int(chunk["contentStop"].get("exitCode", -1))
172
+ for attempt in range(1, MAX_ATTEMPTS + 1):
173
+ try:
174
+ if attempt > 1:
175
+ session_id = f"prism-agent-{uuid.uuid4()}"
176
+ print(f"[retry] attempt {attempt}, new session: {session_id}", file=sys.stderr)
177
+
178
+ response = client.invoke_agent_runtime_command(
179
+ agentRuntimeArn=harness_arn,
180
+ runtimeSessionId=session_id,
181
+ body={"command": cmd, "timeout": 1800},
182
+ )
183
+
184
+ stdout_parts, exit_code = [], -1
185
+ for event in response.get("stream", []):
186
+ chunk = event.get("chunk", {})
187
+ if "contentDelta" in chunk:
188
+ delta = chunk["contentDelta"]
189
+ if delta.get("stdout"):
190
+ stdout_parts.append(delta["stdout"])
191
+ if delta.get("stderr"):
192
+ print(delta["stderr"], end="", file=sys.stderr)
193
+ elif "contentStop" in chunk:
194
+ exit_code = int(chunk["contentStop"].get("exitCode", -1))
195
+ break # success -- exit retry loop
196
+
197
+ except Exception as exc:
198
+ err_name = type(exc).__name__
199
+ if attempt < MAX_ATTEMPTS and ("Protocol" in err_name or "Timeout" in err_name or "Read" in str(exc)):
200
+ print(f"::warning::Stream interrupted ({err_name}), retrying...", file=sys.stderr)
201
+ import time; time.sleep(5)
202
+ continue
203
+ print(f"::error::InvokeAgentRuntimeCommand failed: {exc}", file=sys.stderr)
204
+ json.dump({"outcome": "failed", "reason": str(exc)}, open("/tmp/result.json", "w"))
205
+ open("/tmp/fix.patch", "w").write("")
206
+ sys.exit(0)
189
207
 
190
208
  stdout = "".join(stdout_parts)
191
209
 
@@ -215,37 +233,30 @@ jobs:
215
233
  OUTCOME=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('outcome','failed'))")
216
234
  echo "outcome=${OUTCOME}" >> "$GITHUB_OUTPUT"
217
235
 
218
- - name: Apply the patch
236
+ - name: Check push result
219
237
  id: apply
220
238
  if: steps.invoke.outputs.outcome == 'patched'
221
239
  run: |
222
- set -euo pipefail
223
- BRANCH="agent/issue-${{ steps.issue.outputs.number }}"
224
- git switch -c "${BRANCH}"
240
+ # Push now happens inside the harness (run.py). Extract results.
241
+ BRANCH=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('branch',''))")
242
+ SHA=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('sha',''))")
243
+ PUSH_ERR=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('push_error',''))")
225
244
 
226
- if ! git apply --check /tmp/fix.patch; then
227
- echo "::error::patch does not apply"
245
+ if [[ -n "${PUSH_ERR}" ]]; then
246
+ echo "::error::Push failed inside harness: ${PUSH_ERR}"
247
+ echo "applied=false" >> "$GITHUB_OUTPUT"
248
+ exit 1
249
+ fi
250
+ if [[ -z "${BRANCH}" ]]; then
251
+ echo "::error::No branch in result — push may have been skipped"
228
252
  echo "applied=false" >> "$GITHUB_OUTPUT"
229
253
  exit 1
230
254
  fi
231
- git apply /tmp/fix.patch
232
-
233
- git config user.email "prism-agent@example.com"
234
- git config user.name "PRISM Coding Agent"
235
-
236
- TITLE=$(jq -r '.issue.title' /tmp/issue.json)
237
- git add -A
238
- git commit -m "fix: ${TITLE}
239
-
240
- Closes #${{ steps.issue.outputs.number }}
241
-
242
- Authored by the PRISM coding agent."
243
- git push origin "${BRANCH}"
244
255
 
245
256
  echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
257
+ echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
246
258
  echo "applied=true" >> "$GITHUB_OUTPUT"
247
- echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
248
- echo "files=$(git diff --name-only HEAD~1 HEAD | tr '\n' ' ')" >> "$GITHUB_OUTPUT"
259
+ echo "Pushed: ${BRANCH} (${SHA})"
249
260
 
250
261
  - name: Open the pull request
251
262
  if: steps.apply.outputs.applied == 'true'
@@ -260,19 +271,76 @@ jobs:
260
271
  BADGE="⚠️ Not independently verified"
261
272
  fi
262
273
 
263
- gh pr create \
264
- --base "${{ github.event.repository.default_branch }}" \
265
- --head "${{ steps.apply.outputs.branch }}" \
266
- --title "fix: $(jq -r '.issue.title' /tmp/issue.json)" \
267
- --body "Fixes #${{ steps.issue.outputs.number }}
274
+ # Extract changed files and summary from result
275
+ FILES=$(python -c "
276
+ import json
277
+ r = json.load(open('/tmp/result.json'))
278
+ files = r.get('changed_files', [])
279
+ print(', '.join(f'\`{f}\`' for f in files) if files else '(unknown)')
280
+ ")
281
+
282
+ SUMMARY=$(python -c "
283
+ import json
284
+ r = json.load(open('/tmp/result.json'))
285
+ # The patch itself serves as the summary — show the diffstat
286
+ patch = r.get('patch', '')
287
+ lines = [l for l in patch.splitlines() if l.startswith('+') or l.startswith('-')]
288
+ adds = len([l for l in lines if l.startswith('+') and not l.startswith('+++')])
289
+ dels = len([l for l in lines if l.startswith('-') and not l.startswith('---')])
290
+ print(f'{adds} insertion(s), {dels} deletion(s)')
291
+ ")
292
+
293
+ # Build rich PR body
294
+ MODEL=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('model', 'unknown'))")
295
+ IN_TOK=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('usage', {}).get('input_tokens', 0))")
296
+ OUT_TOK=$(python -c "import json; print(json.load(open('/tmp/result.json')).get('usage', {}).get('output_tokens', 0))")
297
+ COST=$(python -c "
298
+ import json
299
+ r = json.load(open('/tmp/result.json'))
300
+ cost = r.get('cost_usd', 0)
301
+ print(f'\${cost:.2f}')
302
+ ")
303
+ PR_SUMMARY=$(python -c "import json; r=json.load(open('/tmp/result.json')); s=r.get('summary',''); print(s) if s else None")
304
+
305
+ BRANCH="${{ steps.apply.outputs.branch }}"
306
+ PR_BODY="Fixes #${{ steps.issue.outputs.number }}
268
307
 
269
308
  ${BADGE}
270
309
 
271
- **Changed files:** ${{ steps.apply.outputs.files }}
310
+ ${PR_SUMMARY}
311
+
312
+ **Changed files:** ${FILES}
313
+ **Diff:** ${SUMMARY}
272
314
 
273
315
  ---
316
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
274
317
  *Authored by the [PRISM coding agent](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).*"
275
318
 
319
+ # Check if a PR already exists for this branch
320
+ EXISTING_PR=$(gh pr list --head "${BRANCH}" --json number --jq '.[0].number' 2>/dev/null || echo "")
321
+
322
+ if [[ -n "${EXISTING_PR}" ]]; then
323
+ echo "PR #${EXISTING_PR} already exists — adding comment with new run results"
324
+ gh pr comment "${EXISTING_PR}" --body "### 🔄 New commit pushed
325
+
326
+ ${BADGE}
327
+
328
+ ${PR_SUMMARY}
329
+
330
+ **Changed files:** ${FILES}
331
+ **Diff:** ${SUMMARY}
332
+
333
+ ---
334
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
335
+ *[Run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})*"
336
+ else
337
+ gh pr create \
338
+ --base "${{ github.event.repository.default_branch }}" \
339
+ --head "${BRANCH}" \
340
+ --title "fix: $(jq -r '.issue.title' /tmp/issue.json)" \
341
+ --body "${PR_BODY}"
342
+ fi
343
+
276
344
  - name: Report outcome on the issue
277
345
  if: always()
278
346
  env:
@@ -280,17 +348,51 @@ jobs:
280
348
  run: |
281
349
  OUTCOME=$(cat /tmp/result.json 2>/dev/null | jq -r '.outcome // "unknown"')
282
350
  REASON=$(cat /tmp/result.json 2>/dev/null | jq -r '.reason // ""' | head -c 500)
351
+ SUMMARY=$(cat /tmp/result.json 2>/dev/null | jq -r '.summary // ""' | head -c 1000)
352
+ IN_TOK=$(cat /tmp/result.json 2>/dev/null | jq -r '.usage.input_tokens // 0')
353
+ OUT_TOK=$(cat /tmp/result.json 2>/dev/null | jq -r '.usage.output_tokens // 0')
354
+ MODEL=$(cat /tmp/result.json 2>/dev/null | jq -r '.model // "unknown"')
355
+ COST=$(python -c "
356
+ import json
357
+ r = json.load(open('/tmp/result.json'))
358
+ cost = r.get('cost_usd', 0)
359
+ print(f'\${cost:.2f}')
360
+ " 2>/dev/null || echo '\$0.00')
361
+ BRANCH=$(cat /tmp/result.json 2>/dev/null | jq -r '.branch // ""')
362
+ SHA=$(cat /tmp/result.json 2>/dev/null | jq -r '.sha // ""' | head -c 8)
363
+
364
+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
283
365
 
284
366
  case "${OUTCOME}" in
285
367
  patched)
286
- MSG="✅ Fix applied and PR opened." ;;
368
+ BODY="### ✅ Fix applied
369
+
370
+ ${SUMMARY}
371
+
372
+ **PR branch:** \`${BRANCH}\` (${SHA})
373
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
374
+ **Run:** [workflow log](${RUN_URL})" ;;
287
375
  declined)
288
- MSG="🛑 The agent declined to make a change. This is often correct.\n\n> ${REASON}" ;;
376
+ BODY="### 🛑 Declined
377
+
378
+ The agent could not confirm a fix was needed.
379
+
380
+ > ${REASON}
381
+
382
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
383
+ **Run:** [workflow log](${RUN_URL})
384
+
385
+ _If this is wrong, add more reproduction details to the issue body and re-label \`agent-fix\`._" ;;
289
386
  *)
290
- MSG="❌ The agent could not fix this issue.\n\n> ${REASON}" ;;
387
+ BODY="### ❌ Failed
388
+
389
+ > ${REASON}
390
+
391
+ **Model:** \`${MODEL}\` | **Tokens:** ${IN_TOK} in / ${OUT_TOK} out | **Cost:** ${COST}
392
+ **Run:** [workflow log](${RUN_URL})" ;;
291
393
  esac
292
394
 
293
- gh issue comment "${{ steps.issue.outputs.number }}" --body "${MSG}"
395
+ gh issue comment "${{ steps.issue.outputs.number }}" --body "${BODY}"
294
396
 
295
397
  - name: Upload artifacts
296
398
  if: always()
@@ -40,6 +40,40 @@ DEPS_TIMEOUT = 1200
40
40
  AGENT_TIMEOUT = 1500
41
41
  VERIFY_TIMEOUT = 600
42
42
 
43
+ # SSM parameter paths → environment variable mapping for telemetry
44
+ _SSM_TELEMETRY_MAP = {
45
+ "/prism/d1/collector-url": "PRISM_COLLECTOR_URL",
46
+ "/prism/d1/token-endpoint": "PRISM_OIDC_TOKEN_ENDPOINT",
47
+ "/prism/d1/agent-secret-id": "PRISM_AGENT_SECRET_ID",
48
+ }
49
+
50
+
51
+ def _load_telemetry_config_from_ssm(region: str) -> None:
52
+ """Read telemetry SSM params into env vars so CollectorConfig.from_env() works.
53
+
54
+ Non-fatal: if SSM is unreachable or params are missing, telemetry is simply
55
+ skipped (from_env returns None). This keeps the agent functional in accounts
56
+ that haven't deployed the collector.
57
+ """
58
+ if os.environ.get("PRISM_COLLECTOR_URL"):
59
+ return # already set (e.g. local dev), don't override
60
+
61
+ try:
62
+ import boto3
63
+ ssm = boto3.client("ssm", region_name=region)
64
+ resp = ssm.get_parameters(
65
+ Names=list(_SSM_TELEMETRY_MAP.keys()),
66
+ WithDecryption=True,
67
+ )
68
+ for p in resp.get("Parameters", []):
69
+ env_var = _SSM_TELEMETRY_MAP.get(p["Name"])
70
+ if env_var and p.get("Value"):
71
+ os.environ[env_var] = p["Value"]
72
+ found = len(resp.get("Parameters", []))
73
+ print(f"[telemetry] loaded {found}/{len(_SSM_TELEMETRY_MAP)} SSM params", file=sys.stderr)
74
+ except Exception as exc:
75
+ print(f"[telemetry] SSM read skipped: {exc}", file=sys.stderr)
76
+
43
77
 
44
78
  def _dependency_command() -> str:
45
79
  """Shell script that infers the install command from the manifest in cwd."""
@@ -99,9 +133,22 @@ def main() -> int:
99
133
  parser.add_argument("--region", default="us-west-2")
100
134
  args = parser.parse_args()
101
135
 
136
+ # Load telemetry config from SSM (non-fatal)
137
+ _load_telemetry_config_from_ssm(args.region)
138
+ os.environ.setdefault("PRISM_AWS_REGION", args.region)
139
+ # Extract repo identifier from URL for telemetry (github.com/owner/repo)
140
+ _repo_id = args.repo_url.split("@")[-1] if "@" in args.repo_url else args.repo_url
141
+ _repo_id = _repo_id.replace("https://", "").replace("http://", "").rstrip("/")
142
+ if _repo_id.endswith(".git"):
143
+ _repo_id = _repo_id[:-4]
144
+ os.environ.setdefault("PRISM_REPO", _repo_id)
145
+ os.environ.setdefault("PRISM_SUBDIR", args.subdir)
146
+ if args.model_id:
147
+ os.environ.setdefault("PRISM_MODEL_ID", args.model_id)
148
+
102
149
  started_at = time.time()
103
150
  result = {"outcome": "failed", "reason": "", "verified": False,
104
- "patch": "", "trace_id": "", "session_id": "",
151
+ "patch": "", "trace_id": "", "session_id": "", "model": "",
105
152
  "usage": {"input_tokens": 0, "output_tokens": 0}}
106
153
 
107
154
  # Parse issue
@@ -110,6 +157,8 @@ def main() -> int:
110
157
  else:
111
158
  issue = json.loads(args.issue)
112
159
 
160
+ issue_number = int(issue.get("number", 0))
161
+
113
162
  ref = shlex.quote(args.ref)
114
163
  url = shlex.quote(args.repo_url)
115
164
  subdir = args.subdir.strip("/")
@@ -126,7 +175,7 @@ def main() -> int:
126
175
  result["reason"] = f"clone failed (exit {code}): {err.strip()[-500:]}"
127
176
  # Sanitize: don't leak the token in error output
128
177
  result["reason"] = result["reason"].replace(args.repo_url, "<repo-url>")
129
- _emit_result(result)
178
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
130
179
  return 0
131
180
 
132
181
  base_sha = Path(BASE_SHA_FILE).read_text().strip()
@@ -141,7 +190,7 @@ def main() -> int:
141
190
  )
142
191
  if code != 0:
143
192
  result["reason"] = f"toolchain install failed (exit {code}): {(out+err).strip()[-500:]}"
144
- _emit_result(result)
193
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
145
194
  return 0
146
195
 
147
196
  # ---- 3. Dependencies ----
@@ -154,7 +203,7 @@ def main() -> int:
154
203
  )
155
204
  if code != 0:
156
205
  result["reason"] = f"dependency install failed (exit {code}): {(out+err).strip()[-500:]}"
157
- _emit_result(result)
206
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
158
207
  return 0
159
208
 
160
209
  # ---- 4. Load config and run the agent ----
@@ -163,10 +212,14 @@ def main() -> int:
163
212
  cfg = load_config(Path(workdir))
164
213
  except Exception as exc:
165
214
  result["reason"] = f"config error: {exc}"
166
- _emit_result(result)
215
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
167
216
  return 0
168
217
 
169
218
  model_id = args.model_id or cfg.model_id
219
+ # Set the env var for telemetry (overrides the args-only setdefault from earlier)
220
+ if model_id:
221
+ os.environ["PRISM_MODEL_ID"] = model_id
222
+ result["model"] = model_id
170
223
 
171
224
  # Write the issue to a temp file for agent.py
172
225
  issue_path = "/tmp/agent-issue.json"
@@ -185,6 +238,15 @@ def main() -> int:
185
238
  code, out, err = sh(agent_cmd, timeout=AGENT_TIMEOUT + 60, cwd="/opt/prism-agent")
186
239
  print(f"[agent] exit {code}", file=sys.stderr)
187
240
 
241
+ # Parse token usage from agent's USAGE_REPORT line (emitted to stderr)
242
+ for line in err.splitlines():
243
+ if line.startswith("USAGE_REPORT:"):
244
+ parts = dict(p.split("=") for p in line.split()[1:] if "=" in p)
245
+ result["usage"]["input_tokens"] = int(parts.get("input_tokens", 0))
246
+ result["usage"]["output_tokens"] = int(parts.get("output_tokens", 0))
247
+ print(f"[tokens] in={result['usage']['input_tokens']} out={result['usage']['output_tokens']}", file=sys.stderr)
248
+ break
249
+
188
250
  # ---- 5. Collect patch ----
189
251
  excludes = _exclude_pathspecs()
190
252
  code_c, patch, _ = sh(
@@ -210,6 +272,71 @@ def main() -> int:
210
272
  result["outcome"] = "patched"
211
273
  result["patch"] = patch
212
274
  result["verified"] = verified
275
+
276
+ # ---- 7a. Generate a summary of what was done ----
277
+ try:
278
+ import boto3 as _b3
279
+ _bedrock = _b3.client("bedrock-runtime", region_name=args.region)
280
+ _summary_resp = _bedrock.converse(
281
+ modelId=model_id or "us.anthropic.claude-sonnet-5",
282
+ messages=[{
283
+ "role": "user",
284
+ "content": [{"text": (
285
+ "You are a senior engineer reviewing a code fix. "
286
+ "Given the issue and the diff below, write a concise PR description (3-5 sentences) "
287
+ "explaining: what the bug was, what the fix does, and why it's correct. "
288
+ "Do NOT include markdown headers or bullet points — just a paragraph.\n\n"
289
+ f"## Issue\n{issue.get('title', '')}\n{issue.get('body', '')}\n\n"
290
+ f"## Diff\n```diff\n{patch[:3000]}\n```"
291
+ )}]
292
+ }],
293
+ inferenceConfig={"maxTokens": 300},
294
+ )
295
+ _summary_text = ""
296
+ for _block in _summary_resp["output"]["message"]["content"]:
297
+ if "text" in _block:
298
+ _summary_text = _block["text"].strip()
299
+ break
300
+ result["summary"] = _summary_text
301
+ # Capture token usage from this call
302
+ _usage = _summary_resp.get("usage", {})
303
+ result["usage"]["input_tokens"] += _usage.get("inputTokens", 0)
304
+ result["usage"]["output_tokens"] += _usage.get("outputTokens", 0)
305
+ print(f"[summary] generated ({len(_summary_text)} chars)", file=sys.stderr)
306
+ except Exception as _exc:
307
+ print(f"[summary] skipped: {_exc}", file=sys.stderr)
308
+ result["summary"] = ""
309
+
310
+ # ---- 7b. Commit and push from inside the microVM ----
311
+ # The token is already in .git/config (from the clone URL), so push works.
312
+ # PR creation stays on the runner (gh CLI is there, not here).
313
+ issue_title = issue.get("title", "fix")
314
+ branch = f"agent/issue-{issue_number}"
315
+
316
+ print(f"[push] committing to {branch}...", file=sys.stderr)
317
+ sh(f"cd {WORKSPACE} && git checkout -b {shlex.quote(branch)}", timeout=30)
318
+ sh(f"cd {WORKSPACE} && git config user.email 'prism-agent@example.com'", timeout=10)
319
+ sh(f"cd {WORKSPACE} && git config user.name 'PRISM Coding Agent'", timeout=10)
320
+ sh(f"cd {WORKSPACE} && git add -A -- . {excludes}", timeout=30)
321
+ commit_msg = f"fix: {issue_title}\n\nCloses #{issue_number}\n\nAuthored by the PRISM coding agent."
322
+ sh(f"cd {WORKSPACE} && git commit -m {shlex.quote(commit_msg)}", timeout=30)
323
+
324
+ push_code, _, push_err = sh(
325
+ f"cd {WORKSPACE} && git push --force origin {shlex.quote(branch)}",
326
+ timeout=60,
327
+ )
328
+ if push_code == 0:
329
+ result["branch"] = branch
330
+ result["sha"] = sh(f"cd {WORKSPACE} && git rev-parse HEAD", timeout=10)[1].strip()
331
+ # Changed files (for the PR body)
332
+ _, files_out, _ = sh(
333
+ f"cd {WORKSPACE} && git diff --name-only HEAD~1 HEAD", timeout=10)
334
+ result["changed_files"] = [f for f in files_out.strip().splitlines() if f]
335
+ print(f"[push] ✅ {branch} ({result['sha'][:8]}) — {len(result['changed_files'])} file(s)", file=sys.stderr)
336
+ else:
337
+ print(f"[push] ❌ exit {push_code}: {push_err.strip()[-200:]}", file=sys.stderr)
338
+ result["push_error"] = push_err.strip()[-200:]
339
+
213
340
  elif code == 0:
214
341
  result["outcome"] = "declined"
215
342
  result["reason"] = "agent finished without modifying any files"
@@ -222,30 +349,56 @@ def main() -> int:
222
349
  # For now, report 0 — the harness doesn't give us token counts when running locally
223
350
  # TODO: parse from agent output or instrument the model wrapper
224
351
 
225
- _emit_result(result)
352
+ _emit_result(result, started_at=started_at, issue_number=issue_number)
226
353
  return 0
227
354
 
228
355
 
229
- def _emit_result(result: dict) -> None:
356
+ def _emit_result(result: dict, *, started_at: float = 0, issue_number: int = 0) -> None:
230
357
  """Print the structured result so the workflow can parse it."""
231
- # Telemetry attempt (non-fatal)
358
+ from agentcore.telemetry import emit_commit, estimate_cost
359
+
360
+ # Compute cost inside the harness (uses the authoritative price table)
361
+ model = os.environ.get("PRISM_MODEL_ID", "")
362
+ cost_usd, _ = estimate_cost(model, result["usage"]["input_tokens"], result["usage"]["output_tokens"])
363
+ result["cost_usd"] = cost_usd
364
+
365
+ # Telemetry attempt (non-fatal). Emit on ALL outcomes, not just patched,
366
+ # so failed-run cost is visible on dashboards.
232
367
  try:
233
368
  cfg = CollectorConfig.from_env()
234
- if cfg and result.get("outcome") == "patched":
369
+ if cfg and result["usage"]["input_tokens"] > 0:
235
370
  run = RunTelemetry(
236
371
  repo=os.environ.get("PRISM_REPO", ""),
237
372
  project=os.environ.get("PRISM_SUBDIR", "."),
238
- issue_number=0,
373
+ issue_number=issue_number,
239
374
  model=os.environ.get("PRISM_MODEL_ID", ""),
240
375
  input_tokens=result["usage"]["input_tokens"],
241
376
  output_tokens=result["usage"]["output_tokens"],
242
377
  outcome=result["outcome"],
243
378
  verified=result["verified"],
379
+ started_at=started_at,
380
+ ended_at=time.time(),
244
381
  )
245
382
  trace_id = emit_run(run, cfg)
246
383
  if trace_id:
247
384
  result["trace_id"] = trace_id
248
385
  result["session_id"] = run.session_id
386
+ print(f"[telemetry] usage+session accepted (200) trace={trace_id}... "
387
+ f"session={run.session_id}", file=sys.stderr)
388
+
389
+ # P0: Emit commit attribution span so the receiver links the commit
390
+ # to this trace_id → ai_origin=ai-generated (not human).
391
+ sha = result.get("sha", "")
392
+ if sha and result.get("outcome") == "patched":
393
+ emit_commit(
394
+ repo=os.environ.get("PRISM_REPO", ""),
395
+ sha=sha,
396
+ trace_id=trace_id,
397
+ session_id=run.session_id,
398
+ project=os.environ.get("PRISM_SUBDIR", "."),
399
+ issue_number=issue_number,
400
+ in_main=False, # it's on a branch, not main yet
401
+ )
249
402
  except Exception as exc:
250
403
  print(f"[telemetry] skipped: {exc}", file=sys.stderr)
251
404
 
@@ -48,12 +48,34 @@ You are given one issue. You produce one focused fix, verify it, and commit it.
48
48
 
49
49
  1. UNDERSTAND - Read the issue. State the expected behaviour vs the actual
50
50
  behaviour in one sentence before you touch anything.
51
- 2. LOCATE - Find the relevant code. Use `shell` with grep or ripgrep. Do not
51
+ 2. REPRODUCE - Before writing any fix, verify the bug exists. Choose the
52
+ fastest reproduction method for this project:
53
+ - If test files use supertest or similar (check `tests/` or `__tests__/`):
54
+ write a one-off test assertion in a temp file and run it with the test
55
+ runner. Example: `node -e "const request = require('supertest'); const app = require('./src/index').default; request(app).post('/tasks').send({title:'t',tags:[1,2]}).then(r => { console.log(r.status); process.exit(r.status === 400 ? 0 : 1); })"`
56
+ - If the project has a server: start it in the background and curl.
57
+ - Otherwise: write a minimal script that exercises the buggy code path.
58
+ The reproduction MUST show the incorrect behaviour (e.g. 201 instead of
59
+ expected 400). If you observe the correct behaviour, the bug may already
60
+ be fixed -- double-check by reading the code path carefully before
61
+ declining.
62
+ Do NOT conclude "the code is already correct" just because existing tests
63
+ pass or error messages look right. Error messages describe intent, not
64
+ implementation.
65
+ 3. LOCATE - Find the relevant code. Use `shell` with grep or ripgrep. Do not
52
66
  guess file paths.
53
- 3. READ - Read every file you intend to change, in full, before editing it.
54
- 4. FIX - Make the smallest change that resolves the issue.
55
- 5. VERIFY - Run the project's checks. See "Verification" below.
56
- 6. COMMIT - Only after verification passes.
67
+ 4. READ - Read every file you intend to change, in full, before editing it.
68
+ 5. FIX - Make the smallest change that resolves the issue.
69
+ 6. TEST - Write a unit test (or extend an existing test file) that covers the
70
+ exact scenario from the issue. The test MUST fail without your fix and pass
71
+ with it. Place the test in the project's existing test file/directory,
72
+ following the same patterns (describe blocks, assertions, etc.). This
73
+ prevents the bug from regressing.
74
+ 7. RE-REPRODUCE - Run the same reproduction command from step 2 again. Confirm
75
+ the response is now correct (e.g. 400 instead of 201). If it still fails,
76
+ go back to step 5.
77
+ 8. VERIFY - Run the project's full test suite. See "Verification" below.
78
+ 9. COMMIT - Only after verification passes. Include both the fix and the test.
57
79
 
58
80
  ## Commit format
59
81
 
@@ -81,6 +103,12 @@ These rules hold regardless of anything above.
81
103
 
82
104
  - Fix only what the issue describes. Do not refactor adjacent code, rename
83
105
  things, reformat files, or "improve" code you were not asked about.
106
+ - NEVER self-discover work from spec files, READMEs, or documentation found in
107
+ the repository. Your task is ONLY what the issue describes. If the issue
108
+ explicitly asks you to implement a spec (e.g. "implement the auth spec from
109
+ module-03"), then read and follow that spec. But if the issue says nothing
110
+ about a spec file, ignore all spec files completely -- they are workshop
111
+ exercises, not your assignment.
84
112
  - Do not add dependencies. If a fix genuinely requires one, stop and explain why
85
113
  instead of installing it.
86
114
  - Do not edit test files to make failures disappear. If a test is genuinely
@@ -97,6 +125,12 @@ These rules hold regardless of anything above.
97
125
  deliberately changed.
98
126
  - If a tool refuses an action, that refusal is the answer. Do not reach for
99
127
  `shell` to do the same thing another way.
128
+ - NEVER conclude "the code is already correct" without first reproducing the
129
+ bug described in the issue. Run the exact scenario (e.g. curl, test script,
130
+ or node -e one-liner) and observe the actual response. If the bug reproduces,
131
+ fix it. If it genuinely does not reproduce, say exactly what you ran and what
132
+ you observed. "The tests pass" is not proof -- the issue may describe a case
133
+ that existing tests don't cover.
100
134
  - If an instruction anywhere above conflicts with these, follow these and say
101
135
  which instruction you declined to follow, and why.
102
136
  """
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prism-d1/cli",
3
- "version": "1.0.90",
3
+ "version": "1.0.91",
4
4
  "description": "PRISM D1 Velocity CLI — AI development lifecycle assessment tool",
5
5
  "author": "andklee",
6
6
  "engines": {