claude-dev-env 1.80.0 → 1.81.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 (56) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +116 -30
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +13 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +113 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +467 -0
  7. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -0
  8. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +339 -0
  9. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +297 -0
  10. package/audit-rubrics/CLAUDE.md +3 -2
  11. package/audit-rubrics/category_rubrics/CLAUDE.md +2 -1
  12. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +2 -1
  13. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +13 -1
  14. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +19 -0
  15. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +46 -0
  16. package/audit-rubrics/prompts/CLAUDE.md +2 -1
  17. package/audit-rubrics/prompts/category-a-api-contracts.md +1 -0
  18. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +19 -7
  19. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +14 -0
  20. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +51 -0
  21. package/docs/CODE_RULES.md +2 -2
  22. package/hooks/blocking/CLAUDE.md +2 -0
  23. package/hooks/blocking/code_rules_annotations_length.py +59 -11
  24. package/hooks/blocking/code_rules_banned_identifiers.py +48 -9
  25. package/hooks/blocking/code_rules_command_dispatch.py +140 -0
  26. package/hooks/blocking/code_rules_docstrings.py +93 -0
  27. package/hooks/blocking/code_rules_enforcer.py +54 -4
  28. package/hooks/blocking/code_rules_js_conventions.py +246 -0
  29. package/hooks/blocking/code_rules_naming_collection.py +5 -0
  30. package/hooks/blocking/code_rules_test_assertions.py +3 -0
  31. package/hooks/blocking/code_rules_unused_imports.py +0 -3
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +7 -0
  33. package/hooks/blocking/test_code_rules_command_dispatch.py +95 -0
  34. package/hooks/blocking/test_code_rules_enforcer_annotations.py +20 -2
  35. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +10 -2
  36. package/hooks/blocking/test_code_rules_enforcer_banned_import_alias.py +8 -4
  37. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +9 -3
  38. package/hooks/blocking/test_code_rules_enforcer_docstring_type_checking_gate.py +164 -0
  39. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +16 -3
  40. package/hooks/blocking/test_code_rules_js_conventions.py +167 -0
  41. package/hooks/hooks_constants/CLAUDE.md +2 -0
  42. package/hooks/hooks_constants/blocking_check_limits.py +9 -0
  43. package/hooks/hooks_constants/command_dispatch_constants.py +28 -0
  44. package/hooks/hooks_constants/js_conventions_constants.py +54 -0
  45. package/package.json +1 -1
  46. package/rules/docstring-prose-matches-implementation.md +2 -1
  47. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +43 -1
  48. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +2 -2
  49. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +1 -4
  50. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +100 -4
  51. package/skills/autoconverge/SKILL.md +2 -2
  52. package/skills/autoconverge/reference/convergence.md +3 -3
  53. package/skills/autoconverge/reference/stop-conditions.md +1 -1
  54. package/skills/autoconverge/workflow/converge.contract.test.mjs +2 -2
  55. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +245 -1
  56. package/skills/autoconverge/workflow/converge.mjs +129 -23
@@ -16,14 +16,14 @@ export const meta = {
16
16
  phases: [
17
17
  { title: 'Reuse', detail: 'Before convergence, one reuse lens scans the full diff for reuse improvements that are certain, behaviorally identical, and autonomously implementable, and applies the qualifying ones in one commit' },
18
18
  { title: 'Converge', detail: 'Bugbot + code-review + bug-audit in parallel each round; one clean-coder applies all fixes; loop until all three are clean on a stable HEAD' },
19
- { title: 'Copilot gate', detail: 'When the quota pre-check reports Copilot out of premium-request quota or unavailable, skip the gate with no agent spawned; otherwise request a Copilot review and poll up to three times, route findings back into Converge, and when Copilot is down or out of quota log a notice and mark the PR ready with the gate bypassed' },
19
+ { title: 'Copilot gate', detail: 'When the quota pre-check reports Copilot out of premium-request quota or unavailable, skip the gate with no agent spawned; otherwise request a Copilot review and poll up to the configured cap, route findings back into Converge, and when Copilot is down or out of quota log a notice and mark the PR ready with the gate bypassed' },
20
20
  { title: 'Finalize', detail: 'Run check_convergence.py; mark draft=false on a full pass' },
21
21
  ],
22
22
  }
23
23
 
24
24
  const CONFIG = {
25
25
  maxIterations: 20,
26
- copilotMaxPolls: 3,
26
+ copilotMaxPolls: 8,
27
27
  sharedScripts: '$HOME/.claude/skills/pr-converge/scripts',
28
28
  prLoopScripts: '$HOME/.claude/_shared/pr-loop/scripts',
29
29
  bugteamRubric: '$HOME/.claude/skills/bugteam/reference/audit-contract.md',
@@ -200,11 +200,12 @@ async function fixerWithRecovery(head, findings, sourceLabel) {
200
200
 
201
201
  /**
202
202
  * Spawn a fresh code-editor clean-coder agent for a specific edit task (fix-edit,
203
- * conflict-edit, repair-edit, repair-commit, standards-edit, hardening-commit,
204
- * commit-recover, verify-recover). Each task carries its own edit instructions.
203
+ * conflict-edit, repair-edit, repair-commit, standards-edit,
204
+ * standards-resolve-threads, hardening-commit, commit-recover, verify-recover).
205
+ * Each task carries its own edit instructions.
205
206
  * @param {string} task the short task name
206
207
  * @param {object} context task-specific context
207
- * @returns {Promise<object|string>} the structured output, or the transcript string when the 'hardening-commit' resume runs schema-less
208
+ * @returns {Promise<object|string>} the structured output, or the transcript string when a schema-less task ('hardening-commit', 'standards-resolve-threads') runs
208
209
  */
209
210
  function runCodeEditorTask(task, context) {
210
211
  const label = `code-editor:${task}`
@@ -293,6 +294,19 @@ function runCodeEditorTask(task, context) {
293
294
  { label, phase: 'Converge', schema: STANDARDS_EDIT_SCHEMA, agentType: 'clean-coder' },
294
295
  )
295
296
  }
297
+ if (task === 'standards-resolve-threads') {
298
+ const findingsBlock = renderFindingsBlock(context.findings)
299
+ const threadIds = context.findings
300
+ .flatMap((each) => collectFindingThreadIds(each))
301
+ .filter((each) => typeof each === 'number')
302
+ return convergeAgent(
303
+ `You are the THREAD-RESOLUTION step for a code-standard-only round on ${prCoordinates}, HEAD ${context.head} (${context.sourceLabel}). This run already filed the deferred-fix follow-up issue ${context.issueUrl}, so this batch's code-standard findings defer to that same issue. Make NO code edits, NO commit, and NO push — only reply to and resolve the review threads this batch carries.\n\n` +
304
+ `Findings:\n${findingsBlock}\n\n` +
305
+ `For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "Code-standard-only finding — deferred to follow-up issue ${context.issueUrl}." Then resolve the thread by its PRRT_ node id (GraphQL lookup on comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread — not the numeric comment id).\n\n` +
306
+ `Return a one-line summary naming the threads you resolved.`,
307
+ { label, phase: 'Converge', agentType: 'clean-coder' },
308
+ )
309
+ }
296
310
  if (task === 'hardening-commit') {
297
311
  return convergeAgent(
298
312
  `You are the COMMIT step opening the environment-hardening PR (${context.sourceLabel}) for the change staged in ${context.hardeningRepoPath} on branch ${context.hardeningBranch}. The edit step left the hooks/rules edits in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree. Do NOT touch the PR's own branch.\n\n` +
@@ -1288,7 +1302,7 @@ function runCopilotGate(head) {
1288
1302
  ` gh api --method POST repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/requested_reviewers -f 'reviewers[]=copilot-pull-request-reviewer[bot]'\n` +
1289
1303
  `2. Poll for Copilot's review on HEAD ${head}: up to ${CONFIG.copilotMaxPolls} attempts, 360 seconds apart (delay each attempt with "sleep 360", or the PowerShell alternative "Start-Sleep -Seconds 360"). Each attempt: python "${CONFIG.sharedScripts}/fetch_copilot_reviews.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} for the top-level review state, plus gh api "repos/${input.owner}/${input.repo}/pulls/${input.prNumber}/comments" --paginate --slurp for inline comment ids (Copilot's login contains "copilot", case-insensitive). Only count entries whose commit_id starts with ${head}.\n` +
1290
1304
  ` - Out-of-usage notice on HEAD -> return the down result above (clean:true, down:true) and stop.\n` +
1291
- ` - Copilot review present and clean/approved on HEAD -> return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
1305
+ ` - Copilot review present on HEAD whose state is APPROVED, or COMMENTED with no inline findings -> a clean pass: return {sha:${'`'}${head}${'`'}, clean:true, down:false, findings:[]}.\n` +
1292
1306
  ` - Copilot findings on HEAD -> return them (each with its inline comment id in replyToCommentId; category 'code-standard' for pure CODE_RULES/style violations with no behavioral impact, 'bug' otherwise), clean:false, down:false.\n` +
1293
1307
  ` - No review after ${CONFIG.copilotMaxPolls} attempts -> Copilot is down for this run (unreachable, or silently out of quota with no notice): return {sha:${'`'}${head}${'`'}, clean:false, down:true, findings:[]}.\n\n` +
1294
1308
  `Return strictly the schema.`,
@@ -1391,19 +1405,35 @@ function isStandardsOnlyRound(findings) {
1391
1405
  return findings.length > 0 && findings.every((each) => each.category === 'code-standard')
1392
1406
  }
1393
1407
 
1408
+ /**
1409
+ * Decide whether a standards-only round should file the follow-up fix issue and
1410
+ * open the environment-hardening PR. Standards findings are deferred rather than
1411
+ * fixed on this PR, so the same code-standard findings re-surface on every
1412
+ * converge round and on the Copilot gate; without this guard each re-entry files
1413
+ * a fresh duplicate follow-up issue and hardening PR for the one deferred finding
1414
+ * class. The follow-up issue is filed once per convergence run — a round whose
1415
+ * filing succeeds latches the guard, while a round whose filing failed leaves it
1416
+ * clear so a later round retries the filing.
1417
+ * @param {boolean} hasAlreadyFiled true when an earlier standards-only round in this run already filed the follow-up issue
1418
+ * @returns {boolean} true when this round should attempt the follow-up filing
1419
+ */
1420
+ function shouldOpenStandardsFollowUp(hasAlreadyFiled) {
1421
+ return hasAlreadyFiled !== true
1422
+ }
1423
+
1394
1424
  /**
1395
1425
  * Build the standards-deferral note for the closing report, naming the
1396
- * environment-hardening PR only when one was opened this round so the note
1426
+ * environment-hardening PR only when one was opened for this run so the note
1397
1427
  * never claims a PR the skip paths did not produce.
1398
1428
  * @param {number} findingsCount count of deferred code-standard findings
1399
- * @param {boolean} hardeningPrOpened true when the hardening PR was opened this round
1429
+ * @param {boolean} wasHardeningPrOpened true when the hardening PR was opened for this run
1400
1430
  * @returns {string} the human-facing deferral note
1401
1431
  */
1402
- function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1432
+ function standardsDeferralNote(findingsCount, wasHardeningPrOpened) {
1403
1433
  const base = `${findingsCount} code-standard finding(s) deferred to a follow-up fix issue`
1404
- return hardeningPrOpened
1434
+ return wasHardeningPrOpened
1405
1435
  ? `${base} plus an environment-hardening PR — verify both land`
1406
- : `${base} — verify it lands (no environment-hardening PR was opened this round)`
1436
+ : `${base} — verify it lands (no environment-hardening PR was opened for this run)`
1407
1437
  }
1408
1438
 
1409
1439
  /**
@@ -1414,29 +1444,102 @@ function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1414
1444
  * commit (clean-coder makes one commit, pushes, and opens the DRAFT hardening
1415
1445
  * PR). Splitting the edit from the push lets a workflow code-verifier produce the
1416
1446
  * verdict the verified-commit gate requires for the cross-repo hardening commit.
1417
- * This PR's own branch is never touched. When the edit staged no hardening, or
1418
- * the verify step fails, the follow-up issue still stands and the commit step is
1419
- * skipped.
1447
+ * This PR's own branch is never touched. When a hardening PR already opened for
1448
+ * this run, the edit staged no hardening, or the verify step fails, the follow-up
1449
+ * issue still stands and the commit step is skipped.
1420
1450
  * @param {string} head PR HEAD SHA the findings were raised against
1421
1451
  * @param {Array<object>} findings deduped code-standard-only findings
1422
1452
  * @param {string} sourceLabel short description of where the findings came from
1423
- * @returns {Promise<object>} `{ hardeningPrOpened }` true only when the hardening PR was opened this round
1453
+ * @param {boolean} hasHardeningPrAlreadyOpened true when an earlier round already opened the environment-hardening PR for this run, so the verify and commit steps are skipped and no second PR opens while the edit retries the issue filing
1454
+ * @returns {Promise<object>} `{ followUpIssueFiled, issueUrl, hardeningPrOpened }` — followUpIssueFiled true when the standards-edit step returned a non-empty issue URL, issueUrl that filed URL (empty string when the filing failed) so a later reuse-path round can reference it when resolving its own threads, hardeningPrOpened true only when the hardening PR was opened on this call
1424
1455
  */
1425
- async function spawnStandardsFollowUp(head, findings, sourceLabel) {
1456
+ async function spawnStandardsFollowUp(head, findings, sourceLabel, hasHardeningPrAlreadyOpened) {
1426
1457
  const editResult = await runCodeEditorTask('standards-edit', { head, findings, sourceLabel })
1458
+ const followUpIssueFiled = typeof editResult?.issueUrl === 'string' && editResult.issueUrl.length > 0
1459
+ const followUpIssueUrl = followUpIssueFiled ? editResult.issueUrl : ''
1460
+ if (hasHardeningPrAlreadyOpened === true) {
1461
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1462
+ }
1427
1463
  if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
1428
- return { hardeningPrOpened: false }
1464
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1429
1465
  }
1430
1466
  const verifyTranscript = await runVerifierTask('hardening-verify', {
1431
1467
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch,
1432
1468
  })
1433
1469
  if (!verdictPassed(verifyTranscript)) {
1434
- return { hardeningPrOpened: false }
1470
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: false }
1435
1471
  }
1436
1472
  await runCodeEditorTask('hardening-commit', {
1437
1473
  head, sourceLabel, hardeningRepoPath: editResult.hardeningRepoPath, hardeningBranch: editResult.hardeningBranch, issueUrl: editResult.issueUrl,
1438
1474
  })
1439
- return { hardeningPrOpened: true }
1475
+ return { followUpIssueFiled, issueUrl: followUpIssueUrl, hardeningPrOpened: true }
1476
+ }
1477
+
1478
+ /**
1479
+ * Report whether any finding in a batch carries a GitHub review thread that needs
1480
+ * an inline reply and resolution.
1481
+ * @param {Array<object>} findings the batch of findings
1482
+ * @returns {boolean} true when at least one finding carries a review comment id
1483
+ */
1484
+ function findingsCarryThreads(findings) {
1485
+ return findings.some((each) => collectFindingThreadIds(each).length > 0)
1486
+ }
1487
+
1488
+ /**
1489
+ * On the reuse path — after this run already filed the deferred-fix follow-up
1490
+ * issue — reply to and resolve this batch's code-standard review threads against
1491
+ * that same issue. The issue filing and hardening PR stay gated behind the
1492
+ * run-once flags; only the per-batch thread resolution runs here, so a later
1493
+ * standards-only round's bot threads are still marked resolved and the FINALIZE
1494
+ * zero-unresolved-bot-threads gate passes. A batch of in-memory audit findings
1495
+ * that carries no review thread needs no agent, so the resolve step is skipped.
1496
+ * @param {string} head PR HEAD SHA the findings were raised against
1497
+ * @param {Array<object>} findings this batch's deduped code-standard-only findings
1498
+ * @param {string} sourceLabel short description of where the findings came from
1499
+ * @returns {Promise<void>}
1500
+ */
1501
+ async function resolveStandardsThreadsForBatch(head, findings, sourceLabel) {
1502
+ if (!findingsCarryThreads(findings)) {
1503
+ return
1504
+ }
1505
+ await runCodeEditorTask('standards-resolve-threads', {
1506
+ head, findings, sourceLabel, issueUrl: standardsFollowUpIssueUrl,
1507
+ })
1508
+ }
1509
+
1510
+ /**
1511
+ * File the deferred follow-up issue and open the environment-hardening PR at most
1512
+ * once per convergence run, then reuse them. The two guards latch independently:
1513
+ * the follow-up issue latches only when its filing succeeds, and the hardening PR
1514
+ * latches the moment one opens and never re-opens. A standards-only round whose
1515
+ * issue filing already succeeded — in the converge phase or at the Copilot gate —
1516
+ * skips spawnStandardsFollowUp, resolves this batch's own code-standard review
1517
+ * threads against the already-filed issue via resolveStandardsThreadsForBatch, and
1518
+ * returns the remembered hardening outcome. A round whose issue filing failed
1519
+ * re-runs spawnStandardsFollowUp to retry the filing, passing the remembered
1520
+ * hardening state so an already-opened hardening PR is never re-opened. The run
1521
+ * therefore files one follow-up issue and opens one hardening PR rather than a
1522
+ * fresh duplicate per re-entry — while every round still resolves the review
1523
+ * threads its own findings carry, even when the filing must retry after the
1524
+ * hardening PR has opened.
1525
+ * @param {string} head PR HEAD SHA the findings were raised against
1526
+ * @param {Array<object>} findings deduped code-standard-only findings
1527
+ * @param {string} sourceLabel short description of where the findings came from
1528
+ * @returns {Promise<boolean>} true when a hardening PR was opened for this run
1529
+ */
1530
+ async function openStandardsFollowUpOnce(head, findings, sourceLabel) {
1531
+ if (!shouldOpenStandardsFollowUp(hasStandardsFollowUpFiled)) {
1532
+ log(`Standards deferral (${sourceLabel}): reusing the follow-up fix issue already filed for this run rather than filing a duplicate; environment-hardening PR ${wasStandardsHardeningPrOpened ? 'was opened for this run' : 'was not opened for this run'}`)
1533
+ await resolveStandardsThreadsForBatch(head, findings, sourceLabel)
1534
+ return wasStandardsHardeningPrOpened
1535
+ }
1536
+ const standardsOutcome = await spawnStandardsFollowUp(head, findings, sourceLabel, wasStandardsHardeningPrOpened)
1537
+ hasStandardsFollowUpFiled = standardsOutcome?.followUpIssueFiled === true
1538
+ if (hasStandardsFollowUpFiled) {
1539
+ standardsFollowUpIssueUrl = standardsOutcome.issueUrl
1540
+ }
1541
+ wasStandardsHardeningPrOpened = wasStandardsHardeningPrOpened || standardsOutcome?.hardeningPrOpened === true
1542
+ return wasStandardsHardeningPrOpened
1440
1543
  }
1441
1544
 
1442
1545
  let phase = 'CONVERGE'
@@ -1448,6 +1551,9 @@ let bugbotDown = input.bugbotDisabled || false
1448
1551
  let copilotDown = input.copilotDisabled || false
1449
1552
  let copilotNote = null
1450
1553
  let standardsNote = null
1554
+ let hasStandardsFollowUpFiled = false
1555
+ let wasStandardsHardeningPrOpened = false
1556
+ let standardsFollowUpIssueUrl = ''
1451
1557
  let reuseNote = null
1452
1558
 
1453
1559
  const preflightHead = await runGitTask('resolve-head')
@@ -1501,8 +1607,8 @@ while (iterations < CONFIG.maxIterations) {
1501
1607
  const findings = roundOutcome.findings
1502
1608
  if (isStandardsOnlyRound(findings)) {
1503
1609
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
1504
- const standardsOutcome = await spawnStandardsFollowUp(head, findings, 'converge-round')
1505
- standardsNote = standardsDeferralNote(findings.length, standardsOutcome?.hardeningPrOpened === true)
1610
+ const wasHardeningPrOpened = await openStandardsFollowUpOnce(head, findings, 'converge-round')
1611
+ standardsNote = standardsDeferralNote(findings.length, wasHardeningPrOpened)
1506
1612
  const auditResult = await runGeneralUtilityTask('post-clean-audit', { head })
1507
1613
  if (!auditResult?.posted) {
1508
1614
  blocker = cleanAuditBlocker(head, auditResult)
@@ -1564,8 +1670,8 @@ while (iterations < CONFIG.maxIterations) {
1564
1670
  if (copilotOutcome.kind === 'fix') {
1565
1671
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
1566
1672
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
1567
- const standardsOutcome = await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
1568
- standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome?.hardeningPrOpened === true)
1673
+ const wasHardeningPrOpened = await openStandardsFollowUpOnce(head, copilotOutcome.findings, 'copilot')
1674
+ standardsNote = standardsDeferralNote(copilotOutcome.findings.length, wasHardeningPrOpened)
1569
1675
  copilotDown = false
1570
1676
  copilotNote = null
1571
1677
  phase = 'FINALIZE'