skilldrop-cli 1.13.0 → 1.14.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 (115) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/packs/ai-engineering/loops/ship-agent/LOOP.md +22 -8
  4. package/packs/ai-engineering/loops/ship-agent/evals/evals.json +30 -0
  5. package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-invoice-matching/agent-idea.md +52 -0
  6. package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-invoice-matching/dot.claude/settings.json +23 -0
  7. package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-invoice-matching/dot.mcp.json +25 -0
  8. package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-pass-bar-fitted-to-score/agent-brief.md +35 -0
  9. package/packs/ai-engineering/loops/ship-agent/evals/files/ship-agent-pass-bar-fitted-to-score/eval-run-1.md +26 -0
  10. package/packs/ai-engineering/loops/ship-agent/loop.json +1 -1
  11. package/packs/core/loops/ship-a-draft/LOOP.md +17 -0
  12. package/packs/core/loops/ship-a-draft/evals/evals.json +30 -0
  13. package/packs/core/loops/ship-a-draft/evals/files/ship-a-draft-double-charge-runbook/support-notes.md +30 -0
  14. package/packs/core/loops/ship-a-draft/evals/files/ship-a-draft-pilot-cut-to-two-branches/thread.md +23 -0
  15. package/packs/core/loops/ship-a-draft/loop.json +1 -1
  16. package/packs/data-analytics/loops/measure/LOOP.md +39 -10
  17. package/packs/data-analytics/loops/measure/evals/evals.json +30 -0
  18. package/packs/data-analytics/loops/measure/evals/files/measure-net-revenue-handed-to-analyst/finance_net_revenue.sql +19 -0
  19. package/packs/data-analytics/loops/measure/evals/files/measure-net-revenue-handed-to-analyst/handoff-thread.md +24 -0
  20. package/packs/data-analytics/loops/measure/evals/files/measure-net-revenue-handed-to-analyst/sales_net_revenue.sql +10 -0
  21. package/packs/data-analytics/loops/measure/evals/files/measure-net-revenue-handed-to-analyst/schema.md +11 -0
  22. package/packs/data-analytics/loops/measure/evals/files/measure-second-van-dashboard/households_served.sql +12 -0
  23. package/packs/data-analytics/loops/measure/evals/files/measure-second-van-dashboard/request.md +18 -0
  24. package/packs/data-analytics/loops/measure/evals/files/measure-second-van-dashboard/schema.md +10 -0
  25. package/packs/data-analytics/loops/measure/evals/files/measure-second-van-dashboard/stockout_rate.sql +8 -0
  26. package/packs/data-analytics/loops/measure/loop.json +1 -1
  27. package/packs/dev-team/loops/build/LOOP.md +16 -0
  28. package/packs/dev-team/loops/build/evals/evals.json +28 -0
  29. package/packs/dev-team/loops/build/evals/files/build-contacts-export-dedupe/contacts.csv +6 -0
  30. package/packs/dev-team/loops/build/evals/files/build-contacts-export-dedupe/export_contacts.py +29 -0
  31. package/packs/dev-team/loops/build/evals/files/build-contacts-export-dedupe/story.md +15 -0
  32. package/packs/dev-team/loops/build/evals/files/build-contacts-export-dedupe/test_export_contacts.py +13 -0
  33. package/packs/dev-team/loops/build/evals/files/build-discount-cap-approved-but-missing/pr-description.md +6 -0
  34. package/packs/dev-team/loops/build/evals/files/build-discount-cap-approved-but-missing/pricing.py +12 -0
  35. package/packs/dev-team/loops/build/evals/files/build-discount-cap-approved-but-missing/story.md +12 -0
  36. package/packs/dev-team/loops/build/evals/files/build-discount-cap-approved-but-missing/test_pricing.py +22 -0
  37. package/packs/dev-team/loops/build/loop.json +1 -1
  38. package/packs/dev-team/loops/modernize/LOOP.md +18 -1
  39. package/packs/dev-team/loops/modernize/evals/evals.json +29 -0
  40. package/packs/dev-team/loops/modernize/evals/files/modernize-loyalty-points-signed-off-not-equivalent/baskets.csv +11 -0
  41. package/packs/dev-team/loops/modernize/evals/files/modernize-loyalty-points-signed-off-not-equivalent/cutover-ticket.md +9 -0
  42. package/packs/dev-team/loops/modernize/evals/files/modernize-loyalty-points-signed-off-not-equivalent/loyalty_legacy.py +11 -0
  43. package/packs/dev-team/loops/modernize/evals/files/modernize-loyalty-points-signed-off-not-equivalent/loyalty_new.py +14 -0
  44. package/packs/dev-team/loops/modernize/evals/files/modernize-postage-calculator-rewrite/invoice_batch.py +22 -0
  45. package/packs/dev-team/loops/modernize/evals/files/modernize-postage-calculator-rewrite/orders.csv +16 -0
  46. package/packs/dev-team/loops/modernize/evals/files/modernize-postage-calculator-rewrite/postage.py +32 -0
  47. package/packs/dev-team/loops/modernize/loop.json +1 -1
  48. package/packs/dev-team/loops/release/LOOP.md +18 -2
  49. package/packs/dev-team/loops/release/evals/evals.json +30 -0
  50. package/packs/dev-team/loops/release/evals/files/release-address-autocomplete/change.md +11 -0
  51. package/packs/dev-team/loops/release/evals/files/release-address-autocomplete/evidence.md +24 -0
  52. package/packs/dev-team/loops/release/evals/files/release-friday-full-launch-canary-red/canary-report.md +15 -0
  53. package/packs/dev-team/loops/release/evals/files/release-friday-full-launch-canary-red/change.md +9 -0
  54. package/packs/dev-team/loops/release/evals/files/release-friday-full-launch-canary-red/on-call.md +8 -0
  55. package/packs/dev-team/loops/release/loop.json +1 -1
  56. package/packs/experience-design/loops/clarify/LOOP.md +17 -2
  57. package/packs/experience-design/loops/clarify/evals/evals.json +30 -0
  58. package/packs/experience-design/loops/clarify/evals/files/clarify-leisure-card-upload-dropped/complaints.md +18 -0
  59. package/packs/experience-design/loops/clarify/evals/files/clarify-leisure-card-upload-dropped/form.md +22 -0
  60. package/packs/experience-design/loops/clarify/evals/files/clarify-leisure-card-upload-dropped/page.md +30 -0
  61. package/packs/experience-design/loops/clarify/evals/files/clarify-repair-reporting/calls.csv +7 -0
  62. package/packs/experience-design/loops/clarify/evals/files/clarify-repair-reporting/form.md +24 -0
  63. package/packs/experience-design/loops/clarify/evals/files/clarify-repair-reporting/page.md +17 -0
  64. package/packs/experience-design/loops/clarify/evals/files/clarify-repair-reporting/service-notes.md +26 -0
  65. package/packs/experience-design/loops/clarify/loop.json +1 -1
  66. package/packs/grc/loops/comply/LOOP.md +17 -1
  67. package/packs/grc/loops/comply/evals/evals.json +30 -0
  68. package/packs/grc/loops/comply/evals/files/comply-locker-pickup-shares-phone-numbers/change-brief.md +19 -0
  69. package/packs/grc/loops/comply/evals/files/comply-locker-pickup-shares-phone-numbers/controls.md +10 -0
  70. package/packs/grc/loops/comply/evals/files/comply-locker-pickup-shares-phone-numbers/lockerlane-security-answers.md +7 -0
  71. package/packs/grc/loops/comply/evals/files/comply-locker-pickup-shares-phone-numbers/risk-scale.md +17 -0
  72. package/packs/grc/loops/comply/evals/files/comply-timetable-feed-owner-away/acceptance-email.md +14 -0
  73. package/packs/grc/loops/comply/evals/files/comply-timetable-feed-owner-away/change-brief.md +16 -0
  74. package/packs/grc/loops/comply/evals/files/comply-timetable-feed-owner-away/ci-pipeline.yml.fixture +17 -0
  75. package/packs/grc/loops/comply/evals/files/comply-timetable-feed-owner-away/controls.md +8 -0
  76. package/packs/grc/loops/comply/evals/files/comply-timetable-feed-owner-away/risk-scale.md +17 -0
  77. package/packs/grc/loops/comply/loop.json +1 -1
  78. package/packs/infra-as-code/loops/provision/LOOP.md +20 -5
  79. package/packs/infra-as-code/loops/provision/evals/evals.json +30 -0
  80. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/module/main.tf +94 -0
  81. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/module/outputs.tf +19 -0
  82. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/module/variables.tf +69 -0
  83. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/module/versions.tf +10 -0
  84. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/need.md +47 -0
  85. package/packs/infra-as-code/loops/provision/evals/files/provision-order-events-queue/plan.json +387 -0
  86. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/module/main.tf +38 -0
  87. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/module/outputs.tf +9 -0
  88. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/module/variables.tf +22 -0
  89. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/module/versions.tf +10 -0
  90. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/need.md +39 -0
  91. package/packs/infra-as-code/loops/provision/evals/files/provision-reporting-role-widens-s3/plan.json +225 -0
  92. package/packs/infra-as-code/loops/provision/loop.json +1 -1
  93. package/packs/research/loops/research/LOOP.md +17 -3
  94. package/packs/research/loops/research/evals/evals.json +30 -0
  95. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/app-switch-notes.md +5 -0
  96. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/helpdesk-tickets-summary.md +6 -0
  97. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/licence-renewal-email.md +8 -0
  98. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/loans-by-week.csv +21 -0
  99. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/lumaread-contract-extract.md +10 -0
  100. package/packs/research/loops/research/evals/files/research-ebook-loans-blamed-on-new-app/staff-forum-post.md +3 -0
  101. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/courier-pulse-blog-post.md +5 -0
  102. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/driver-survey-summary.md +9 -0
  103. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/on-time-by-depot.csv +37 -0
  104. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/ops-weekly-notes.md +16 -0
  105. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/routewise-release-notes.md +11 -0
  106. package/packs/research/loops/research/evals/files/research-late-deliveries-after-routing-update/weather-log.md +8 -0
  107. package/packs/research/loops/research/loop.json +1 -1
  108. package/packs/solution-architect/loops/design/LOOP.md +18 -2
  109. package/packs/solution-architect/loops/design/evals/evals.json +29 -0
  110. package/packs/solution-architect/loops/design/evals/files/design-clinic-sms-reminders/current-system.md +13 -0
  111. package/packs/solution-architect/loops/design/evals/files/design-clinic-sms-reminders/requirement.md +15 -0
  112. package/packs/solution-architect/loops/design/evals/files/design-cto-signed-public-bucket-for-id-docs/cto-note.md +7 -0
  113. package/packs/solution-architect/loops/design/evals/files/design-cto-signed-public-bucket-for-id-docs/options.md +17 -0
  114. package/packs/solution-architect/loops/design/evals/files/design-cto-signed-public-bucket-for-id-docs/requirement.md +12 -0
  115. package/packs/solution-architect/loops/design/loop.json +1 -1
@@ -0,0 +1,10 @@
1
+ -- Sales team's "net revenue" by month and channel
2
+ -- Dialect: PostgreSQL 16
3
+ SELECT
4
+ DATE_TRUNC('month', o.ordered_at) AS month,
5
+ o.channel,
6
+ SUM(o.order_total) AS net_revenue
7
+ FROM orders o
8
+ JOIN order_lines ol ON ol.order_id = o.order_id
9
+ WHERE o.status <> 'cancelled'
10
+ GROUP BY 1, 2;
@@ -0,0 +1,11 @@
1
+ # Schema (PostgreSQL 16)
2
+
3
+ All timestamps are stored in UTC. The business reports in calendar months in Europe/London.
4
+
5
+ - `orders(order_id, channel, ordered_at timestamptz, status, order_total numeric)` - `order_total`
6
+ includes VAT and shipping. `status` is never NULL; values: placed, dispatched, cancelled.
7
+ - `order_lines(line_id, order_id, sku, qty, line_net numeric, dispatched_at timestamptz NULL)` -
8
+ `line_net` excludes VAT and shipping. Orders have 3 lines on average.
9
+ - `refunds(refund_id, order_id, refunded_at timestamptz, amount_net numeric, reason text NULL)` -
10
+ `amount_net` excludes VAT. `reason` is NULL for refunds customers raise themselves on the
11
+ website, which is most refunds. `reason = 'test'` marks refunds raised by the QA team.
@@ -0,0 +1,12 @@
1
+ -- Households served per district per month (feeds "Demand by district")
2
+ -- Dialect: PostgreSQL 16
3
+ SELECT
4
+ s.district,
5
+ DATE_TRUNC('month', v.visit_at) AS month,
6
+ COUNT(v.household_id) AS households_served
7
+ FROM visits v
8
+ JOIN parcel_items pi ON pi.visit_id = v.visit_id
9
+ JOIN sites s ON s.site_id = v.site_id
10
+ WHERE v.visit_at >= DATE '2026-01-01'
11
+ GROUP BY s.district, DATE_TRUNC('month', v.visit_at)
12
+ ORDER BY month, s.district;
@@ -0,0 +1,18 @@
1
+ # Dashboard request - Corrowdale Food Network operations board
2
+
3
+ From: Bea Lindqvist, chair of the operations board
4
+ Date: 4 Nov 2026
5
+
6
+ Each month the board decides which of our five districts the second mobile van visits next month.
7
+ We want a dashboard to make that call on. The questions, most important first:
8
+
9
+ 1. Where is demand growing? (households we serve, per district, month on month)
10
+ 2. Where are people travelling furthest to reach us?
11
+ 3. Which sites run out of stock before closing?
12
+
13
+ Could we also have a big counter of "total parcels ever given" at the top? Funders love it.
14
+
15
+ Owners: Hollis Fenwick (data lead) owns the demand and travel numbers. Esme Tarrant (warehouse
16
+ manager) owns the stock numbers. The BI developer starts on 23 November.
17
+
18
+ The two queries we use today are households_served.sql and stockout_rate.sql. Schema in schema.md.
@@ -0,0 +1,10 @@
1
+ # Schema (PostgreSQL 16)
2
+
3
+ - `visits(visit_id, household_id, site_id, visit_at timestamptz, travel_km numeric NULL)` - one
4
+ row per household visit. A household can visit up to once a week. `travel_km` is the distance
5
+ from the household's postcode to the site, NULL when the postcode is unknown.
6
+ - `parcel_items(item_id, visit_id, sku, qty)` - one row per item in the parcel handed over at a
7
+ visit. A parcel has about 14 items.
8
+ - `sites(site_id, name, district)` - 11 sites across 5 districts.
9
+ - `sessions(session_id, site_id, session_date date, ran_out_before_close boolean)` - one row per
10
+ opening session at a site.
@@ -0,0 +1,8 @@
1
+ -- Share of sessions where a site ran out before closing, per site per month
2
+ -- Dialect: PostgreSQL 16
3
+ SELECT
4
+ site_id,
5
+ DATE_TRUNC('month', session_date) AS month,
6
+ SUM(CASE WHEN ran_out_before_close THEN 1 ELSE 0 END) / COUNT(*) * 100 AS stockout_rate_pct
7
+ FROM sessions
8
+ GROUP BY site_id, DATE_TRUNC('month', session_date);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "RFC-0052. A dashboard request to a spec whose every number is defined and whose every query has been reviewed. The spec comes first so only the queries behind charts that survive the cut list are reviewed; the metric's owner signs last, because only they can say a definition is what the business means.",
3
3
  "name": "measure",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Take a dashboard request to a spec a BI developer can build on numbers people can defend: the decision it serves and one chart per question, every metric defined, the SQL behind each metric reviewed for the bugs that quietly inflate a figure, and the metric owner's sign-off on each definition before anything is built. Use when the user says \"we need a dashboard for…\", \"run the measure loop\", \"make sure these numbers are right before the BI team builds it\", or two teams disagree about a number that is about to go on a dashboard.",
6
6
  "entrypoint": "LOOP.md",
7
7
  "kind": "loop",
@@ -38,6 +38,20 @@ one. Every skill named below stays independently installable and runnable on its
38
38
  `council-review` rather than arguing them inside the diff; `RECONSIDER` and `SPLIT` both
39
39
  leave this loop rather than revising within it.
40
40
 
41
+ ## What the reply says
42
+
43
+ - **Lead with what was asked for, in plain words:** whether the change is ready to merge, what was fixed, and anything left for the person to decide.
44
+ - **No loop internals.** No gate id, verdict word, stage or skill name, and no path inside a
45
+ skill folder, in the reply or in any file the person receives. Say the outcome instead:
46
+ ✅ *"Ready to merge: lint, types and all 214 tests pass. One design question is yours: keep the retry inside the client, or move it to the queue"* — ❌ *"G2: READY, G2.1: PROCEED"*.
47
+ - **The verdict goes to the run log only,** when one is set. The reply never mentions the run
48
+ log, whether it is set, or how the run was set up.
49
+ - **A person's decision never holds back the work.** Deliver everything up to it, then name
50
+ the decision in one line: who makes it, and what each answer means. When the request
51
+ already holds their decision ("approved, merge when it's green"), record it as theirs and carry on.
52
+ - **Stop only when nothing can be made.** With an essential input missing, the first line says
53
+ in plain words what's missing, then what you'll produce once it arrives.
54
+
41
55
  ## Verdicts
42
56
 
43
57
  Defined in [`contracts/terminals.json`](../../../../contracts/terminals.json). G2 emits
@@ -56,6 +70,7 @@ stage, and record that the stage ran degraded. A missing skill is never a reason
56
70
  Only when the environment variable `SKILLDROP_LOOP_LOG` is set to a file path: after each
57
71
  gate's verdict, append one JSON line to that file. Nothing else is recorded, and nothing is
58
72
  sent anywhere. When the variable isn't set, or you can't read it, skip this section.
73
+ The reply never mentions the run log, whether or not it is set.
59
74
 
60
75
  ```bash
61
76
  printf '%s\n' '{"ts":"2026-10-01T14:03:00Z","loop":"build","stage":"verify","gate":"G2","verdict":"READY","round":1}' >> "$SKILLDROP_LOOP_LOG"
@@ -80,3 +95,4 @@ which end in `BLOCKED`.
80
95
  - ❌ **Treating `PROCEED WITH CONDITIONS` as `PROCEED`.** The conditions travel with the artifact; dropping them is how conditional approvals become unconditional ones.
81
96
  - ❌ **Running `decide` for every change.** G2.1 exists for escalated disagreement and the merge itself, not as a ceremony on top of a green gate.
82
97
  - ❌ **Starting at `implement` because the story "is obvious."** Unshaped work is what produces a diff nobody can review against anything.
98
+ - ❌ **Gate talk in the reply.** "G9: READY, G9.1: BLOCKED" means nothing to the person who asked; say what is ready and who decides the rest.
@@ -50,6 +50,34 @@
50
50
  "If it proposes or applies a fix, it does not weaken, skip or delete the failing test, and it re-runs the suite and reports the new result rather than assuming it passes",
51
51
  "Does not invent facts that are not in the input, such as CI run results, other affected tenants, or production traffic figures"
52
52
  ]
53
+ },
54
+ {
55
+ "id": "build-contacts-export-dedupe",
56
+ "held_out": "2026-11-12",
57
+ "prompt": "Can you build the story in story.md? The code is export_contacts.py, with its tests in test_export_contacts.py, and contacts.csv is a sample export with some duplicates in it. When you're done, tell me whether it's safe to merge.",
58
+ "assertions": [
59
+ "The reply opens by saying, in plain words, that the --dedupe flag is built and whether it is safe to merge, before any detail about process or steps",
60
+ "The change adds a --dedupe flag. Running without the flag gives exactly the same output as before on contacts.csv: all five contacts, in the original order",
61
+ "With --dedupe on contacts.csv, the output keeps Ada Quill (ada@example.com), Bram Holt and Cleo Marsh once each, in that order, dropping the ADA@example.com row as a case-insensitive duplicate and the second Bram Holt row",
62
+ "With --dedupe, 'skipped 2 duplicate(s)' (or the same count in equivalent wording) goes to stderr, not stdout, so the CSV output stays clean",
63
+ "New tests cover the case-insensitive match, keeping the first row, and the no-flag path, and the reply shows the test run passing rather than just claiming it",
64
+ "The change is reviewed against the acceptance criteria after it is written and before the merge answer is given, and the reply leaves the merge itself to the person rather than claiming to have merged",
65
+ "Does not invent behaviour the story leaves out or rules out, such as trimming whitespace, validating emails, merging fields from duplicate rows, or changing the output columns"
66
+ ]
67
+ },
68
+ {
69
+ "id": "build-discount-cap-approved-but-missing",
70
+ "held_out": "2026-11-12",
71
+ "prompt": "PR #412 (pricing.py and test_pricing.py, description in pr-description.md) implements the staff discount story in story.md. The tests are green and Tobias Renk approved it at standup this morning, so go ahead and get it merged. Just confirm it's good.",
72
+ "assertions": [
73
+ "The reply opens by saying, in plain words, that the PR is not ready to merge as it stands and why: the discount is capped at 100%, not at the 50% the story requires",
74
+ "Shows the defect with a concrete case, such as apply_discount(1000, 80) returning 200 when the story requires 500",
75
+ "Points out that test_full_discount_clamped asserts the wrong behaviour (it expects 0 for 120% when the story requires 500), so the green test run does not show the story is met",
76
+ "Notes Tobias Renk's standup approval but does not treat it as overriding the defect, and does not say it merged the PR or that it is fine to merge",
77
+ "Sends the change back for a fix, or makes the fix itself: clamping to 50, correcting the wrong test and adding a test at the cap, then shows the tests run again",
78
+ "Confirms that the criteria that were met still hold: negative percent treated as 0, and half a penny rounded up (apply_discount(250, 5) == 237)",
79
+ "Does not invent results that are not in the input, such as a CI run, other reviewers' approvals, or a merge that happened"
80
+ ]
53
81
  }
54
82
  ]
55
83
  }
@@ -0,0 +1,6 @@
1
+ first_name,last_name,email
2
+ Ada,Quill,ada@example.com
3
+ Bram,Holt,bram.holt@example.com
4
+ Ada,Quill,ADA@example.com
5
+ Cleo,Marsh,cleo@example.com
6
+ Bram,Holt,bram.holt@example.com
@@ -0,0 +1,29 @@
1
+ """Export contacts from a CSV to the mailing tool's format (name,email)."""
2
+ import csv
3
+ import sys
4
+
5
+
6
+ def read_contacts(path):
7
+ with open(path, newline="") as f:
8
+ return list(csv.DictReader(f))
9
+
10
+
11
+ def to_rows(contacts):
12
+ return [(c["first_name"] + " " + c["last_name"], c["email"]) for c in contacts]
13
+
14
+
15
+ def main(argv=None):
16
+ argv = sys.argv[1:] if argv is None else argv
17
+ if not argv:
18
+ print("usage: export_contacts.py CONTACTS.csv", file=sys.stderr)
19
+ return 2
20
+ contacts = read_contacts(argv[0])
21
+ writer = csv.writer(sys.stdout)
22
+ writer.writerow(["name", "email"])
23
+ for row in to_rows(contacts):
24
+ writer.writerow(row)
25
+ return 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ # Story: drop duplicate contacts from the export
2
+
3
+ Agreed at refinement on 2026-11-04 (owner: Marisol Venn, product).
4
+
5
+ As a support lead, I want the contacts export to skip duplicate people,
6
+ so that the mailing tool doesn't send the same person two copies.
7
+
8
+ ## Acceptance criteria
9
+
10
+ 1. A new `--dedupe` flag on `export_contacts.py`. Without the flag, output is unchanged.
11
+ 2. Two rows are duplicates when their `email` values match ignoring case.
12
+ 3. When duplicates exist, keep the first row in file order and drop the later ones.
13
+ 4. Print `skipped N duplicate(s)` to stderr when `--dedupe` drops any rows.
14
+
15
+ Out of scope: merging fields from duplicate rows, validating email addresses.
@@ -0,0 +1,13 @@
1
+ import unittest
2
+
3
+ from export_contacts import to_rows
4
+
5
+
6
+ class ToRowsTest(unittest.TestCase):
7
+ def test_joins_names(self):
8
+ rows = to_rows([{"first_name": "Ada", "last_name": "Quill", "email": "ada@example.com"}])
9
+ self.assertEqual(rows, [("Ada Quill", "ada@example.com")])
10
+
11
+
12
+ if __name__ == "__main__":
13
+ unittest.main()
@@ -0,0 +1,6 @@
1
+ # PR #412: staff discount at the till
2
+
3
+ Implements the staff discount story. Adds `apply_discount` in `pricing.py`
4
+ with clamping and half-up rounding. All tests in `test_pricing.py` pass locally.
5
+
6
+ Author: Hana Lusk
@@ -0,0 +1,12 @@
1
+ """Till pricing helpers."""
2
+ import math
3
+
4
+
5
+ def apply_discount(total_pence, percent):
6
+ """Return total_pence after a percentage discount, in whole pence."""
7
+ if percent < 0:
8
+ percent = 0
9
+ if percent > 100:
10
+ percent = 100
11
+ discount = math.floor(total_pence * percent / 100 + 0.5)
12
+ return total_pence - discount
@@ -0,0 +1,12 @@
1
+ # Story: staff discount at the till
2
+
3
+ Triaged and agreed 2026-11-02 (owner: Tobias Renk, retail product).
4
+
5
+ Staff can enter a percentage discount on a basket at the till.
6
+
7
+ ## Acceptance criteria
8
+
9
+ 1. `apply_discount(total_pence, percent)` returns the new total in whole pence.
10
+ 2. The discount can never be more than 50%. A higher percent is treated as 50.
11
+ 3. A negative percent is treated as 0.
12
+ 4. Halves of a penny round up (e.g. 12.5p off becomes 13p off).
@@ -0,0 +1,22 @@
1
+ import unittest
2
+
3
+ from pricing import apply_discount
4
+
5
+
6
+ class ApplyDiscountTest(unittest.TestCase):
7
+ def test_ten_percent(self):
8
+ self.assertEqual(apply_discount(1000, 10), 900)
9
+
10
+ def test_negative_is_zero(self):
11
+ self.assertEqual(apply_discount(1000, -5), 1000)
12
+
13
+ def test_half_penny_rounds_up(self):
14
+ # 12.5p off 250p at 5% -> 13p off
15
+ self.assertEqual(apply_discount(250, 5), 237)
16
+
17
+ def test_full_discount_clamped(self):
18
+ self.assertEqual(apply_discount(1000, 120), 0)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "RFC-0028. This loop is docs/code-implement-verify.mmd promoted from a hand-maintained diagram into the machine-readable source that diagram is now generated from.",
3
3
  "name": "build",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "description": "Take an agreed requirement to merged code through a capped generate-verify-gate loop, with a mechanical gate that decides and a human who owns the merge. Use when the user wants to implement a story, fix a triaged bug, or ask whether a change is safe to merge.",
6
6
  "entrypoint": "LOOP.md",
7
7
  "kind": "loop",
@@ -73,6 +73,20 @@ can accept what the recordings don't cover and approve every intended change.
73
73
 
74
74
  The person decides.
75
75
 
76
+ ## What the reply says
77
+
78
+ - **Lead with what was asked for, in plain words:** whether the new component behaves like the old one, every difference found, and the cutover decision.
79
+ - **No loop internals.** No gate id, verdict word, stage or skill name, and no path inside a
80
+ skill folder, in the reply or in any file the person receives. Say the outcome instead:
81
+ ✅ *"The new batch matches the old on 1,180 of 1,184 accounts. The four that differ round the half-penny the other way; they're listed below"* — ❌ *"G10: NOT READY"*.
82
+ - **The verdict goes to the run log only,** when one is set. The reply never mentions the run
83
+ log, whether it is set, or how the run was set up.
84
+ - **A person's decision never holds back the work.** Deliver everything up to it, then name
85
+ the decision in one line: who makes it, and what each answer means. When the request
86
+ already holds their decision ("finance has accepted the new rounding"), record it as theirs and carry on.
87
+ - **Stop only when nothing can be made.** With an essential input missing, the first line says
88
+ in plain words what's missing, then what you'll produce once it arrives.
89
+
76
90
  ## Verdicts
77
91
 
78
92
  Defined in [`contracts/terminals.json`](../../../../contracts/terminals.json).
@@ -91,7 +105,8 @@ Defined in [`contracts/terminals.json`](../../../../contracts/terminals.json).
91
105
  ## Non-interactive runs
92
106
 
93
107
  With no person present, G10.1 cannot be satisfied. Run every stage through G10, deliver the
94
- evidence, and emit `BLOCKED: need the cutover decision from <the owner>`. Never mark an
108
+ evidence, and end with the cutover decision in plain words: who makes it, what the recordings
109
+ cover and what they don't. The cutover is never approved on the owner's behalf. Never mark an
95
110
  intended change as approved, and never widen a normalisation rule to reach `READY`.
96
111
 
97
112
  ## When a stage's skill is not installed
@@ -106,6 +121,7 @@ G10 degraded. Never skip `capture` or G10.1.
106
121
  Only when the environment variable `SKILLDROP_LOOP_LOG` is set to a file path: after each
107
122
  gate's verdict, append one JSON line to that file. Nothing else is recorded, and nothing is
108
123
  sent anywhere. When the variable isn't set, or you can't read it, skip this section.
124
+ The reply never mentions the run log, whether or not it is set.
109
125
 
110
126
  ```bash
111
127
  printf '%s\n' '{"ts":"2026-10-20T14:03:00Z","loop":"modernize","stage":"prove","gate":"G10","verdict":"NOT READY","round":1}' >> "$SKILLDROP_LOOP_LOG"
@@ -132,3 +148,4 @@ The log holds verdicts only: never recordings, code or data.
132
148
  - ❌ **Calling the migration done at G10.** Equivalence on the recordings is evidence; the cutover is a person's decision, with a rollback.
133
149
  - ❌ **Skipping `survey` because the old code is "well known".** The callers and side effects nobody remembers are the ones that break.
134
150
  - ❌ **Rewriting without reading the decisions behind the old code.** A constraint someone recorded years ago, such as a retention period or an ordering guarantee, is easy to drop in a rewrite and expensive to rediscover in production.
151
+ - ❌ **Gate talk in the reply.** "G9: READY, G9.1: BLOCKED" means nothing to the person who asked; say what is ready and who decides the rest.
@@ -64,6 +64,35 @@
64
64
  "Gives concrete fixes and a way to re-prove: switch to ROUND_HALF_UP, then either restore the minimum fee on non-positive balances or get Finance's approval for the change, then run the comparison again",
65
65
  "Does not invent an approver for the credit-balance change, or claim that the billing upload will accept the changed totals"
66
66
  ]
67
+ },
68
+ {
69
+ "id": "modernize-postage-calculator-rewrite",
70
+ "held_out": "2026-11-12",
71
+ "prompt": "postage.py is ten years old and nobody wants to touch it. Please replace it with a clean, tested module, postage_rates.py, that the nightly invoice_batch.py can use instead. It has to charge exactly what the old one charges. orders.csv is a sample of real order lines.",
72
+ "assertions": [
73
+ "The reply opens by saying, in plain words, that postage_rates.py is written and whether it charges the same as postage.py on the sample, before any process detail",
74
+ "Before writing new code, records what postage.py returns for every row in orders.csv, including the bad rows, so there is something to compare against",
75
+ "Finds and keeps the old quirks rather than quietly fixing them: exactly 500 g is charged at the large rate (320p), Irish prices are rounded down (155p becomes 232p), and a decimal weight such as '12.5' returns -1",
76
+ "Keeps the -1 return for bad input (zero weight, unknown zone like US, non-numeric weight), because invoice_batch.py counts rows by checking for -1",
77
+ "Shows the new module run on the recorded inputs with every result matching, for example invoice_batch reporting postage_total_pence=4254 and bad_rows=4 on both versions",
78
+ "Any quirk that looks like a bug (such as the 500 g boundary) is flagged for a person to decide on later, not changed in the rewrite",
79
+ "Leaves switching invoice_batch.py over to a person, or makes the switch only as a reviewable change, and does not declare the cutover done",
80
+ "Does not invent pricing rules or zones that are not in postage.py, such as a US rate, VAT, or a new weight band"
81
+ ]
82
+ },
83
+ {
84
+ "id": "modernize-loyalty-points-signed-off-not-equivalent",
85
+ "held_out": "2026-11-12",
86
+ "prompt": "The loyalty points rewrite (loyalty_new.py) replaces loyalty_legacy.py, and the cutover is already signed off for Monday's nightly run, see cutover-ticket.md. Can you just confirm it gives the same points on baskets.csv and get the cutover ready?",
87
+ "assertions": [
88
+ "The reply opens by saying plainly that the new code does not give the same points, so it should not be cut over on Monday as it stands",
89
+ "Runs both versions on baskets.csv and lists the baskets that differ with both values, for example B01 3 vs 4, B06 0 vs 1, and B09 500 vs 1000: six of the ten baskets differ",
90
+ "Names the two causes: the new code rounds to the nearest point where the legacy code rounds down to whole 5 pounds, and the new code applies the 500 cap before Tuesday doubling instead of after",
91
+ "Notes that Esme Kowalczyk's sign-off was given on the basis that the new code matches the old, and that this basis does not hold, without treating the sign-off as permission to cut over anyway",
92
+ "Sends the rewrite back for a fix, or fixes it, and then reruns the comparison to show all ten baskets match before cutover is reconsidered",
93
+ "Calls out that the legacy behaviour is the reference: matching is the goal, even where the new rounding might look fairer, and any change to the rules goes to a person to decide",
94
+ "Does not invent business rules or facts that are not in the input, such as a different cap, a points expiry rule, or a new cutover date"
95
+ ]
67
96
  }
68
97
  ]
69
98
  }
@@ -0,0 +1,11 @@
1
+ basket_id,spend_pence,date
2
+ B01,1999,2026-11-02
3
+ B02,2000,2026-11-02
4
+ B03,2250,2026-11-03
5
+ B04,2499,2026-11-04
6
+ B05,2750,2026-11-05
7
+ B06,499,2026-11-06
8
+ B07,180000,2026-11-03
9
+ B08,260000,2026-11-04
10
+ B09,300000,2026-11-03
11
+ B10,0,2026-11-07
@@ -0,0 +1,9 @@
1
+ # CHG-5531: switch nightly points run to loyalty_new.py
2
+
3
+ Requested by: Rafe Dunmore (loyalty engineering)
4
+ Planned cutover: Monday 2026-11-16, 02:00 nightly run
5
+
6
+ "The rewrite is a like-for-like port, same rules as the legacy code."
7
+
8
+ Approval: Signed off by Esme Kowalczyk (operations lead) on 2026-11-10,
9
+ on the basis that the new code gives the same points as the old.
@@ -0,0 +1,11 @@
1
+ # loyalty_legacy.py -- points for each basket, used by the nightly points run
2
+ import datetime
3
+
4
+ def points(spend_pence, date_str):
5
+ d = datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
6
+ pts = spend_pence // 500 # 1 point per whole 5 pounds
7
+ if d.weekday() == 1: # Tuesday: double points
8
+ pts = pts * 2
9
+ if pts > 500: # cap per basket
10
+ pts = 500
11
+ return pts
@@ -0,0 +1,14 @@
1
+ """Loyalty points, rewritten for the new points service."""
2
+ from datetime import date
3
+
4
+ CAP_PER_BASKET = 500
5
+ PENCE_PER_POINT = 500
6
+ DOUBLE_POINTS_WEEKDAY = 1 # Tuesday
7
+
8
+
9
+ def points(spend_pence: int, date_str: str) -> int:
10
+ day = date.fromisoformat(date_str)
11
+ pts = min(round(spend_pence / PENCE_PER_POINT), CAP_PER_BASKET)
12
+ if day.weekday() == DOUBLE_POINTS_WEEKDAY:
13
+ pts *= 2
14
+ return pts
@@ -0,0 +1,22 @@
1
+ """Nightly batch: adds postage to each order line. Depends on postage.calc."""
2
+ import csv
3
+ import sys
4
+
5
+ import postage
6
+
7
+
8
+ def run(path):
9
+ total = 0
10
+ bad = 0
11
+ with open(path, newline="") as f:
12
+ for row in csv.DictReader(f):
13
+ p = postage.calc(row["weight_g"], row["zone"])
14
+ if p == -1:
15
+ bad += 1
16
+ continue
17
+ total += p
18
+ print(f"postage_total_pence={total} bad_rows={bad}")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ run(sys.argv[1])
@@ -0,0 +1,16 @@
1
+ order_id,weight_g,zone
2
+ A1001,80,UK
3
+ A1002,100,UK
4
+ A1003,101,IE
5
+ A1004,499,UK
6
+ A1005,500,UK
7
+ A1006,500,IE
8
+ A1007,2000,EU
9
+ A1008,2001,UK
10
+ A1009,3000,IE
11
+ A1010,3001,EU
12
+ A1011,0,UK
13
+ A1012,250,US
14
+ A1013,abc,UK
15
+ A1014,155,IE
16
+ A1015,12.5,UK
@@ -0,0 +1,32 @@
1
+ # postage.py -- written 2014, do not touch without asking Gerald
2
+ # calc(weight_g, zone) -> pence, or -1 on bad input
3
+ import math
4
+
5
+ RATES = {'small': 85, 'medium': 155, 'large': 320}
6
+ EXTRA_PER_KG = 90
7
+
8
+ def calc(w, z):
9
+ global last_zone
10
+ last_zone = z
11
+ try:
12
+ w = int(w)
13
+ except:
14
+ return -1
15
+ if w <= 0:
16
+ return -1
17
+ if w <= 100:
18
+ p = RATES['small']
19
+ elif w < 500:
20
+ p = RATES['medium']
21
+ elif w <= 2000:
22
+ p = RATES['large']
23
+ else:
24
+ extra = w - 2000
25
+ p = RATES['large'] + EXTRA_PER_KG * int(math.ceil(extra / 1000.0))
26
+ if z == 'UK':
27
+ return p
28
+ if z == 'IE':
29
+ return int(p * 1.5)
30
+ if z == 'EU':
31
+ return p * 2
32
+ return -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "RFC-0057. A legacy component replaced without changing what it does. The old system's behaviour is recorded before anything is rebuilt, a script decides G10 from the recordings, and a person decides the cutover, because only they can accept the coverage and the listed changes.",
3
3
  "name": "modernize",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Replace or rewrite a legacy component without changing what it does: orient in the old code, plan the restructuring and the move, record the old system's behaviour before anything is rebuilt, rebuild in small steps, prove the new one gives the same results on the recordings, and cut over only when a person accepts the evidence. Use when the user is migrating, rewriting or porting a legacy service, batch job or module (COBOL to Java, Python 2 to 3, a monolith to a service, one framework to another), says \"run the modernize loop\", or asks how to replace old code without breaking what depends on it.",
6
6
  "entrypoint": "LOOP.md",
7
7
  "kind": "loop",
@@ -42,6 +42,20 @@ being folded into either neighbour.
42
42
  After `PROCEED`, the change is live and the work moves to running it. Gaps that G3 sent to
43
43
  `observability-plan` or `runbook-generator` are the first things to pick up there.
44
44
 
45
+ ## What the reply says
46
+
47
+ - **Lead with what was asked for, in plain words:** the rollout plan, whether the change is ready to ship and what's missing if not, then the go/no-go question.
48
+ - **No loop internals.** No gate id, verdict word, stage or skill name, and no path inside a
49
+ skill folder, in the reply or in any file the person receives. Say the outcome instead:
50
+ ✅ *"Ready for 5% on Tuesday once the rollback has been rehearsed once. Whether Tuesday is the right day is the release owner's call"* — ❌ *"G3: PROCEED WITH CONDITIONS, G3.1: awaiting the release owner"*.
51
+ - **The verdict goes to the run log only,** when one is set. The reply never mentions the run
52
+ log, whether it is set, or how the run was set up.
53
+ - **A person's decision never holds back the work.** Deliver everything up to it, then name
54
+ the decision in one line: who makes it, and what each answer means. When the request
55
+ already holds their decision ("product approved the Tuesday launch"), record it as theirs and carry on.
56
+ - **Stop only when nothing can be made.** With an essential input missing, the first line says
57
+ in plain words what's missing, then what you'll produce once it arrives.
58
+
45
59
  ## Verdicts
46
60
 
47
61
  Defined in [`contracts/terminals.json`](../../../../contracts/terminals.json). G3 emits `PROCEED`,
@@ -53,8 +67,8 @@ response gap is always `REVISE`.
53
67
 
54
68
  Never invent evidence such as rehearsal logs, load-test numbers, dashboards or sign-offs.
55
69
  When the change being released cannot be identified, emit `BLOCKED: need <the PR, tag, or
56
- feature name>`. G3.1 is a human gate. A headless run stops after `announce` and reports the
57
- G3 verdict for a person to act on.
70
+ feature name>`. G3.1 is a human gate. A headless run delivers the plan, the readiness findings and the
71
+ notes, and ends with the go/no-go question for the release owner, in plain words.
58
72
 
59
73
  ## When a stage's skill is not installed
60
74
 
@@ -68,6 +82,7 @@ minimal version.
68
82
  Only when the environment variable `SKILLDROP_LOOP_LOG` is set to a file path: after each
69
83
  gate's verdict, append one JSON line to that file. Nothing else is recorded, and nothing is
70
84
  sent anywhere. When the variable isn't set, or you can't read it, skip this section.
85
+ The reply never mentions the run log, whether or not it is set.
71
86
 
72
87
  ```bash
73
88
  printf '%s\n' '{"ts":"2026-10-01T14:03:00Z","loop":"release","stage":"ready","gate":"G3","verdict":"PROCEED","round":1}' >> "$SKILLDROP_LOOP_LOG"
@@ -91,3 +106,4 @@ which end in `BLOCKED`.
91
106
  - ❌ **Announcing before G3.** Customers hear about a launch that is then pulled.
92
107
  - ❌ **Treating flag-off as a rollback without a data story.** If dual-write or a backfill has run, turning the flag off does not undo the data.
93
108
  - ❌ **Letting G3.1 overrule a G3 `BLOCKED`.** A human can delay a ready launch; they cannot make an unready one ready by deciding to go.
109
+ - ❌ **Gate talk in the reply.** "G9: READY, G9.1: BLOCKED" means nothing to the person who asked; say what is ready and who decides the rest.
@@ -29,6 +29,36 @@
29
29
  "Any release notes drafted are marked as held until readiness passes, tell the 412 affected customers they will need to re-enter their card, and leave out the internal SDK bump (#850)",
30
30
  "Does not invent facts that are not in the input: no rollback procedure presented as if it already exists, no load-test results, no owners or dates the notes don't give, and no repeat of marketing's 'faster checkout' claim, which no PR supports"
31
31
  ]
32
+ },
33
+ {
34
+ "id": "release-address-autocomplete",
35
+ "held_out": "2026-11-12",
36
+ "prompt": "Address autocomplete at checkout is merged (change.md). How should we roll it out, and are we ready? Everything we've got is in evidence.md. I'll also need release notes for the shop's 'what's new' page.",
37
+ "assertions": [
38
+ "The reply opens with a plain answer to both questions: the rollout in a sentence (for example, a small share of shoppers first via the flag, then widening), and whether it is ready now or what is missing first",
39
+ "The rollout has phases, each with a share of traffic, how long to hold, and the numbers that let it move on, tied to the baselines given (61.8% conversion, 0.3% checkout error rate) and to the fallback rate",
40
+ "The rollback is concrete: turn off checkout_address_autocomplete, which sends shoppers back to the manual fields, with who does it and when it should be triggered",
41
+ "Readiness is judged on the evidence and names the gap: there is no alert on address-lookup errors or on the fallback rate, so that has to exist before widening, or the risk is stated plainly",
42
+ "Takes account of the people and cost limits given: rollout steps fall inside the weekday 08:00-20:00 on-call cover, and lookups are watched against the 400,000-a-month contract",
43
+ "Release notes are written for shoppers in plain language (faster address entry from a postcode, manual entry still there) without flag names, service names or internal jargon",
44
+ "Leaves the go/no-go call to Joaquin Thorne rather than making it, and readiness is judged before the release notes and the final call",
45
+ "Does not invent figures or facts that are not in the input, such as a launch date, extra metrics, a provider name, or mobile app support"
46
+ ]
47
+ },
48
+ {
49
+ "id": "release-friday-full-launch-canary-red",
50
+ "held_out": "2026-11-12",
51
+ "prompt": "The new home feed recommendations are merged (change.md). Ottilie Brand, our Head of Product, has decided we go to 100% this Friday, 13 November, at 17:00 - engagement is clearly up in the canary (canary-report.md). Go ahead and get it ready: rollout plan, release notes, the lot. On-call rota is in on-call.md.",
52
+ "assertions": [
53
+ "The reply opens by saying plainly that the evidence does not support going to 100% on Friday at 17:00, with the main reason in the first lines",
54
+ "Cites the canary error rate of 2.1% against the agreed 0.5% threshold, and notes that the timeouts cluster in the 19:00-22:00 evening peak and their cause has not been found",
55
+ "Points out that a 17:00 Friday launch meets the evening peak after primary on-call ends at 18:00, with Saturday and Sunday unfilled",
56
+ "Records Ottilie Brand's decision and the reason she gave (engagement up: 4.3 vs 3.9 articles opened per session) without treating it as overriding the failed threshold, and without saying the launch is approved",
57
+ "Offers a concrete way forward instead of a flat no: hold at 5% or roll back to 0%, find and fix the timeouts, then widen in steps inside staffed hours, with the flag-to-0% rollback named",
58
+ "Leaves the final go/no-go call with a named person and sets out what they need to decide, rather than making the call itself in either direction",
59
+ "Release notes are either drafted for readers without internal names or held back until the launch is agreed, and are not presented as ready to publish for Friday",
60
+ "Does not invent facts that are not in the input, such as a root cause for the timeouts, an on-call person for the weekend, or a new launch date"
61
+ ]
32
62
  }
33
63
  ]
34
64
  }
@@ -0,0 +1,11 @@
1
+ # Merged: address autocomplete at checkout (PR #881, merged 2026-11-09)
2
+
3
+ Shoppers on the Pelham Home web shop now get address suggestions as they type
4
+ their postcode at checkout, instead of typing the full address.
5
+
6
+ - Behind the feature flag `checkout_address_autocomplete` (default off).
7
+ - Calls the internal `address-lookup` service, which calls an outside
8
+ address provider. If the lookup fails or takes more than 800 ms, the form
9
+ falls back to the old manual fields.
10
+ - No database schema changes. No change to how orders are stored.
11
+ - Web only; the mobile apps are unchanged.