cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -2,12 +2,13 @@
2
2
 
3
3
  This is a reviewed component policy, not proof of every possible dependency.
4
4
  Unknown paths and shared/infrastructure changes require the full estate. The
5
- full background/release runs remain the cross-component backstop.
5
+ scheduled full run and the release run remain the cross-component backstop.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import hashlib
11
+ import importlib.util
11
12
  import json
12
13
  import os
13
14
  from pathlib import Path, PurePosixPath
@@ -26,6 +27,45 @@ PRESENTATION_FILES = frozenset(
26
27
  )
27
28
  SMOKE_FILES = ('tests/test_cli_smoke.py', 'tests/test_lib_json_envelope.py')
28
29
  PRESENTATION_PYTEST = ('tests/test_dashboard_handler_static.py',)
30
+ # The reviewed memory publication set: both topic stores and the topic metadata.
31
+ # A private test pins it to the publication engine's own definition. Its
32
+ # configuration is NOT memory, so a change to it is ordinary task work.
33
+ MEMORY_PUBLICATION_SET = ('.claude-memory/**', '.agentmem/codex-memory/**', '.agentmem/topics.json')
34
+ MEMORY_CONFIGURATION = '.agentmem/config.json'
35
+ # Every test module that reads the memory corpus, beside the smoke files and
36
+ # the Doc lint harness: together they certify a memory-only candidate.
37
+ MEMORY_PYTEST = ('tests/test_cctally_memory.py', 'tests/test_agent_workflows.py',
38
+ 'tests/test_session_workflow_texts.py')
39
+ # The capture publishes regular files only (a link is refused), so a memory
40
+ # path that is anything else on either side is not a memory publication.
41
+ REGULAR_MODES = ('100644', '100755')
42
+ # The instruction-text class (#894): agent-instruction prose whose every reader
43
+ # is one of INSTRUCTIONS_PYTEST. Markdown at any depth under these trees,
44
+ # nested CLAUDE.md/AGENTS.md included.
45
+ INSTRUCTION_TREES = ('.agent-workflows/skills/', '.claude/skills/', '.agents/skills/',
46
+ '.agent-workflows/contracts/', 'docs/superpowers/')
47
+ INSTRUCTION_NAMES = ('CLAUDE.md', 'AGENTS.md')
48
+ # Every nested instruction file outside the trees. A new one is a structural
49
+ # change to the instruction chain: its readers depend on where it lands, so it
50
+ # selects the full estate until a reviewed policy change lists it here.
51
+ NESTED_INSTRUCTION_FILES = ('bin/AGENTS.md', 'dashboard/AGENTS.md')
52
+ # The generator inputs of the root AGENTS.md (its configuration is not one).
53
+ INSTRUCTION_INPUTS = ('.agentmem/codex.md', '.agentmem/manifest.json')
54
+ # Every test module that reads, validates or scans an instruction path,
55
+ # beside the smoke files and the Doc lint harness.
56
+ INSTRUCTIONS_PYTEST = ('tests/test_agent_workflows.py', 'tests/test_session_workflow_texts.py',
57
+ 'tests/test_cctally_memory.py', 'tests/test_session_issues.py',
58
+ 'tests/test_session_provenance.py', 'tests/test_linux_matrix_gate.py',
59
+ 'tests/test_release_internals.py', 'tests/test_quota_budget_error_harness.py',
60
+ 'tests/test_session_policy.py', 'tests/test_lib_share.py',
61
+ 'tests/test_dashboard_source_kernel.py', 'tests/test_share_privacy_detector.py',
62
+ 'tests/test_fixture_cache.py')
63
+ # An instruction path is eligible only as a non-executable regular file.
64
+ INSTRUCTION_MODES = ('100644',)
65
+ # The two session execution plans are documentation: their only readers are
66
+ # people and the Doc lint harness, so a plan refresh takes the documentation
67
+ # focus. Exactly these two paths; any other docs/ Markdown is unclassified.
68
+ EXECUTION_PLAN_PATHS = ('docs/cctally-claude-execution-plan.md', 'docs/cctally-execution-plan.md')
29
69
 
30
70
 
31
71
  def _git(repo, *args):
@@ -58,13 +98,113 @@ def basis(repo, base_rev, head='HEAD', *, working=True):
58
98
  'paths': sorted(committed | dirty)}
59
99
 
60
100
 
101
+ def memory_publication_path(path):
102
+ """Whether `path` is in the memory publication set (the engine's glob semantics)."""
103
+ path = path.strip('/')
104
+ for pattern in MEMORY_PUBLICATION_SET:
105
+ if pattern.endswith('/**'):
106
+ prefix = pattern[:-3]
107
+ if path == prefix or path.startswith(prefix + '/'):
108
+ return True
109
+ elif path == pattern:
110
+ return True
111
+ return False
112
+
113
+
114
+ def _tree_modes(repo, rev, paths):
115
+ """{path: git mode} of the entries `paths` name in `rev` (absent paths are omitted)."""
116
+ out = _git(repo, '--literal-pathspecs', 'ls-tree', '-z', '--full-tree', rev, '--', *paths)
117
+ modes = {}
118
+ for record in out.split(b'\0'):
119
+ if record:
120
+ meta, name = record.split(b'\t', 1)
121
+ modes[name.decode('utf-8', errors='strict')] = meta.split()[0].decode()
122
+ return modes
123
+
124
+
125
+ def irregular_memory_paths(repo, base, head_oid, paths, *, working=True):
126
+ """The memory paths that are not a regular file (or absent) at the base, at the head,
127
+ or (with `working`) in the working tree: the file types the publication set does not define."""
128
+ irregular = set()
129
+ for rev in (base, head_oid):
130
+ irregular.update(name for name, mode in _tree_modes(repo, rev, paths).items()
131
+ if mode not in REGULAR_MODES)
132
+ if working:
133
+ for path in paths:
134
+ target = Path(repo) / path
135
+ if target.is_symlink() or (target.exists() and not target.is_file()):
136
+ irregular.add(path)
137
+ return sorted(irregular & set(paths))
138
+
139
+
140
+ def _blob_prefixes(repo, rev, paths):
141
+ """{path: its first two bytes} for the blobs `paths` name in `rev`, in one git call."""
142
+ if not paths:
143
+ return {}
144
+ request = b''.join(f'{rev}:{path}\n'.encode('utf-8') for path in paths)
145
+ proc = subprocess.run(['git', '--no-optional-locks', '-C', str(repo), 'cat-file', '--batch'],
146
+ input=request, capture_output=True)
147
+ if proc.returncode:
148
+ raise ValueError('git cat-file failed: ' + proc.stderr.decode(errors='replace').strip())
149
+ out, offset, prefixes = proc.stdout, 0, {}
150
+ for path in paths:
151
+ end = out.index(b'\n', offset)
152
+ header = out[offset:end].split()
153
+ if len(header) != 3 or header[1] != b'blob':
154
+ raise ValueError('git cat-file returned no blob for ' + path)
155
+ size = int(header[2])
156
+ prefixes[path] = out[end + 1:end + 1 + min(size, 2)]
157
+ offset = end + 1 + size + 1
158
+ return prefixes
159
+
160
+
161
+ def ineligible_instruction_paths(repo, base, head_oid, paths, *, working=True):
162
+ """The instruction paths that are not a non-executable regular file without a
163
+ `#!` first line at the base, at the head, or (with `working`) on disk. Absence
164
+ on any side is eligible. With `working=False` only the two commits are read."""
165
+ paths = sorted(p for p in paths if _sane(p) and _instruction_class(p) == 'instruction')
166
+ if not paths:
167
+ return []
168
+ ineligible = set()
169
+ for rev in (base, head_oid):
170
+ modes = _tree_modes(repo, rev, paths)
171
+ ineligible.update(name for name, mode in modes.items() if mode not in INSTRUCTION_MODES)
172
+ regular = sorted(name for name, mode in modes.items() if mode in INSTRUCTION_MODES)
173
+ ineligible.update(name for name, prefix in _blob_prefixes(repo, rev, regular).items()
174
+ if prefix == b'#!')
175
+ if working:
176
+ for path in paths:
177
+ target = Path(repo) / path
178
+ if target.is_symlink():
179
+ ineligible.add(path)
180
+ elif target.exists():
181
+ if not target.is_file() or target.stat().st_mode & 0o111:
182
+ ineligible.add(path)
183
+ else:
184
+ with target.open('rb') as handle:
185
+ if handle.read(2) == b'#!':
186
+ ineligible.add(path)
187
+ return sorted(ineligible & set(paths))
188
+
189
+
190
+ def _memory_only(paths, irregular):
191
+ """A memory-only candidate: every path in the publication set, each a regular file."""
192
+ return (irregular is not None and bool(paths) and not irregular
193
+ and all(memory_publication_path(p) and _sane(p) for p in paths))
194
+
195
+
196
+ def _sane(path):
197
+ p = PurePosixPath(path)
198
+ return not (p.is_absolute() or '..' in p.parts or any(c.isspace() for c in path))
199
+
200
+
61
201
  def policy_digest(repo):
62
202
  return 'sha256:' + hashlib.sha256(
63
203
  (Path(repo) / 'bin/_lib_merge_gate.py').read_bytes()).hexdigest()
64
204
 
65
205
 
66
206
  def _document(path):
67
- return path in ('README.md', 'CHANGELOG.md') or (
207
+ return path in ('README.md', 'CHANGELOG.md') or path in EXECUTION_PLAN_PATHS or (
68
208
  path.startswith('docs/commands/') and path.endswith('.md'))
69
209
 
70
210
 
@@ -72,7 +212,32 @@ def _frontend_test(path):
72
212
  return path.startswith('dashboard/web/src/') and path.endswith(('.test.ts', '.test.tsx'))
73
213
 
74
214
 
75
- def select(repo, paths):
215
+ def _instruction_class(path):
216
+ """'instruction' for an instruction path, a full-selection reason for an
217
+ instruction-file name at an unreviewed location, or None for any other path."""
218
+ if path.endswith('.md') and path.startswith(INSTRUCTION_TREES):
219
+ return 'instruction'
220
+ if PurePosixPath(path).name in INSTRUCTION_NAMES:
221
+ if path in INSTRUCTION_NAMES or path in NESTED_INSTRUCTION_FILES:
222
+ return 'instruction'
223
+ return 'unreviewed nested instruction file: ' + path
224
+ if path in INSTRUCTION_INPUTS:
225
+ return 'instruction'
226
+ return None
227
+
228
+
229
+ def select(repo, paths, *, irregular=None, instruction_evidence=None):
230
+ """The reviewed selection of `paths`.
231
+
232
+ `irregular` is the list of memory paths that are not regular files
233
+ (`irregular_memory_paths`); only a caller that computed it can have a
234
+ candidate selected as memory-only. Any other candidate is selected over its
235
+ whole diff, where a memory path is unclassified.
236
+
237
+ `instruction_evidence` is the list of instruction paths that are not
238
+ non-executable regular files (`ineligible_instruction_paths`); only a caller
239
+ that computed it can have an instruction path selected focused.
240
+ """
76
241
  repo = Path(repo)
77
242
  manifest = json.loads((repo / 'tests/authoritative-test-manifest.json').read_text())
78
243
  private = (repo / '.mirror-allowlist').exists()
@@ -82,10 +247,28 @@ def select(repo, paths):
82
247
  raise ValueError('invalid harness manifest')
83
248
  paths = sorted(set(paths))
84
249
  reasons, components = [], set()
85
- for path in paths:
250
+ memory = _memory_only(paths, irregular)
251
+ if memory:
252
+ components.add('memory')
253
+ for path in ([] if memory else paths):
86
254
  p = PurePosixPath(path)
87
- if p.is_absolute() or '..' in p.parts or any(c.isspace() for c in path):
255
+ if path in (irregular or ()):
256
+ reasons.append('memory path that is not a regular file: ' + path)
257
+ elif p.is_absolute() or '..' in p.parts or any(c.isspace() for c in path):
88
258
  reasons.append('unclassified path: ' + path)
259
+ elif '__pycache__' in p.parts:
260
+ # Terminal: the skill comparison never reads cache content, so no
261
+ # class may admit it (the memory-only shortcut is decided above).
262
+ reasons.append('runtime cache path: ' + path)
263
+ elif (instruction := _instruction_class(path)) is not None:
264
+ if instruction != 'instruction':
265
+ reasons.append(instruction)
266
+ elif instruction_evidence is None:
267
+ reasons.append('instruction path without file evidence: ' + path)
268
+ elif path in instruction_evidence:
269
+ reasons.append('instruction path that is not a non-executable regular file: ' + path)
270
+ else:
271
+ components.add('instructions')
89
272
  elif _document(path):
90
273
  components.add('documentation')
91
274
  elif path in PRESENTATION_FILES or _frontend_test(path):
@@ -96,11 +279,17 @@ def select(repo, paths):
96
279
  components.add('presentation')
97
280
  else:
98
281
  reasons.append('shared, high-risk, or unclassified path: ' + path)
282
+ if 'instructions' in components and 'presentation' in components:
283
+ reasons.append('instruction text and presentation are not certified together')
99
284
  harnesses = {'doc-lint'}
100
285
  files = set(SMOKE_FILES)
101
286
  if 'presentation' in components:
102
287
  harnesses.add('frontend')
103
288
  files.update(PRESENTATION_PYTEST)
289
+ if 'memory' in components:
290
+ files.update(MEMORY_PYTEST)
291
+ if 'instructions' in components:
292
+ files.update(INSTRUCTIONS_PYTEST)
104
293
  if not harnesses <= set(names):
105
294
  reasons.append('required merge harness absent from manifest')
106
295
  if any(not (repo / p).is_file() for p in files):
@@ -113,19 +302,32 @@ def select(repo, paths):
113
302
  'omittedHarnesses': [n for n in names if not full and n not in harnesses],
114
303
  'selectedPytestFiles': [] if full else sorted(files),
115
304
  'components': sorted(components), 'reasons': reasons,
116
- 'policyDigest': policy_digest(repo), 'maxWallSeconds': MAX_SECONDS,
305
+ 'policyDigest': policy_digest(repo),
306
+ 'maxWallSeconds': MAX_SECONDS,
117
307
  }
118
308
 
119
309
 
120
310
  def plan(repo, base_rev, head='HEAD', *, working=True):
121
311
  b = basis(repo, base_rev, head, working=working)
122
- result = select(repo, b['paths'])
312
+ irregular = None
313
+ if b['paths'] and all(memory_publication_path(p) for p in b['paths']):
314
+ irregular = irregular_memory_paths(repo, b['baseOid'], b['headOid'], b['paths'], working=working)
315
+ evidence = None
316
+ if any(_sane(p) and _instruction_class(p) == 'instruction' for p in b['paths']):
317
+ evidence = ineligible_instruction_paths(repo, b['baseOid'], b['headOid'], b['paths'],
318
+ working=working)
319
+ result = select(repo, b['paths'], irregular=irregular, instruction_evidence=evidence)
123
320
  result['mergeBasis'] = b
124
321
  return result
125
322
 
126
323
 
127
- def filter_legs(plan_record, directory):
128
- """Narrow both expected-node legs to selected files; retain serial ownership."""
324
+ def filter_legs(plan_record, directory, quarantined=()):
325
+ """Narrow both expected-node legs to selected files; retain serial ownership.
326
+
327
+ bin/cctally-test-all applies tests/quarantine.json to the legs first, so a
328
+ selected file whose every node is quarantined has none left. Such a file
329
+ (one that ``quarantined``, the list's node ids, names) contributes no
330
+ targets; any other selected file without a recorded node refuses."""
129
331
  directory = Path(directory)
130
332
  selected = set(plan_record['selectedPytestFiles'])
131
333
  found = set()
@@ -134,78 +336,20 @@ def filter_legs(plan_record, directory):
134
336
  nodes = [n for n in path.read_text().splitlines() if n.split('::')[0] in selected]
135
337
  found.update(n.split('::')[0] for n in nodes)
136
338
  path.write_text(''.join(n + '\n' for n in nodes))
137
- if found != selected:
138
- raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(selected - found)))
139
-
140
-
141
- def background_health(rows):
142
- """Newest completed full verification wins; running work hides nothing."""
143
- for row in rows:
144
- if row.get('head_branch') != 'main' or row.get('status') != 'completed':
145
- continue
146
- if row.get('event') not in ('schedule', 'workflow_dispatch') and row.get('fullSuite') is not True:
147
- continue
148
- passed = row.get('conclusion') == 'success'
149
- return {'state': 'passed' if passed else 'failed', 'allowFocused': passed,
150
- 'runId': row.get('id'), 'headSha': row.get('head_sha')}
151
- return {'state': 'unavailable', 'allowFocused': False, 'runId': None, 'headSha': None}
339
+ absent = selected - found - {node.split('::')[0] for node in quarantined}
340
+ if absent:
341
+ raise ValueError('selected files absent from recorded pytest estate: ' + ', '.join(sorted(absent)))
152
342
 
153
343
 
154
- def read_background_health(repo):
155
- """Read actual full test-macos job results, never a receipt-discharge skip.
344
+ def quarantine_ids(repo):
345
+ """The pytest node ids tests/quarantine.json lists; none when the tree carries no list.
156
346
 
157
- Existing full CI jobs provide a bootstrap before the new schedule lands.
158
- A successful workflow with a skipped full job is not full verification.
159
- """
160
- def gh(*args):
161
- process = subprocess.run(['gh', *args], cwd=repo, capture_output=True,
162
- text=True, timeout=20)
163
- if process.returncode:
164
- raise ValueError('GitHub full-verification evidence is unavailable')
165
- return json.loads(process.stdout)
166
- try:
167
- name = gh('repo', 'view', '--json', 'nameWithOwner')['nameWithOwner']
168
- rows = gh('api', f'repos/{name}/actions/workflows/ci.yml/runs?branch=main&per_page=20')['workflow_runs']
169
- for row in rows:
170
- if row.get('status') != 'completed':
171
- # GitHub reuses the run ID on retry: the earlier failed
172
- # attempt disappears from this listing while the retry runs.
173
- # Do not let that mutation reveal an older successful run.
174
- if row.get('run_attempt', 1) != 1:
175
- return background_health([])
176
- continue
177
- jobs = gh('api', f'repos/{name}/actions/runs/{row["id"]}/jobs?per_page=100')['jobs']
178
- full = next((j for j in jobs if j.get('name') == 'test-macos'
179
- and j.get('status') == 'completed'
180
- and j.get('conclusion') != 'skipped'), None)
181
- if full is not None:
182
- record = dict(row, fullSuite=True, conclusion=full.get('conclusion'))
183
- return background_health([record])
184
- if row.get('event') in ('schedule', 'workflow_dispatch'):
185
- # A scheduled/manual attempt must actually run the full job.
186
- # Only receipt-discharged ordinary pushes may be skipped while
187
- # looking for the last full verification.
188
- return background_health([])
189
- # ci.yml can skip its declared full job on a successful private
190
- # main push only through a successful release/receipt gate. The
191
- # skipped job plus successful workflow is evidence of discharge;
192
- # a merely successful classifier job alone is not (it may emit
193
- # discharged=false). Missing jobs, cancellation, and failures
194
- # must not silently inherit an older pass.
195
- discharged = (
196
- row.get('event') == 'push' and row.get('conclusion') == 'success'
197
- and any(j.get('name') == 'test-macos'
198
- and j.get('status') == 'completed'
199
- and j.get('conclusion') == 'skipped' for j in jobs)
200
- and any(j.get('name') in ('receipt-gate', 'release-stamp-gate')
201
- and j.get('status') == 'completed'
202
- and j.get('conclusion') == 'success' for j in jobs)
203
- )
204
- if not discharged:
205
- return background_health([])
206
- return background_health([])
207
- except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired):
208
- return background_health([])
347
+ Parsed by the shared bin/_lib_quarantine.py; harness cases are not pytest
348
+ nodes and are left to their harness. bin/cctally-test-all validated the
349
+ list at admission, before this runs."""
350
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
351
+ import _lib_quarantine
352
+ return _lib_quarantine.pytest_ids(_lib_quarantine.load(repo))
209
353
 
210
354
 
211
355
  def supervise(command, seconds):
@@ -264,7 +408,6 @@ def main(argv=None):
264
408
  parser = argparse.ArgumentParser(description=__doc__)
265
409
  parser.add_argument('--repo', default=str(Path(__file__).resolve().parents[1]))
266
410
  parser.add_argument('--base')
267
- parser.add_argument('--health', action='store_true')
268
411
  parser.add_argument('--required-mode', action='store_true')
269
412
  parser.add_argument('--shell', action='store_true')
270
413
  parser.add_argument('--filter-legs')
@@ -275,18 +418,20 @@ def main(argv=None):
275
418
  if len(args.deadline) < 2:
276
419
  parser.error('deadline requires seconds and a command')
277
420
  return supervise(args.deadline[1:], int(args.deadline[0]))
278
- if args.health:
279
- health = read_background_health(args.repo)
280
- print(json.dumps(health, sort_keys=True))
281
- return 0 if health['allowFocused'] else 3
282
421
  if not args.base:
283
- parser.error('--base is required outside --health')
422
+ parser.error('--base is required')
284
423
  try:
285
424
  result = plan(args.repo, args.base)
286
425
  if args.required_mode:
287
- print(result['mode'])
426
+ # `<mode> <reason>`: the wrapper decides on the mode alone; the
427
+ # reason names the focused components for whoever reads the log.
428
+ if result['mode'] == 'full':
429
+ reason = 'reviewed-selection'
430
+ else:
431
+ reason = '+'.join(result['components']) or 'none'
432
+ print(f"{result['mode']} {reason}")
288
433
  elif args.filter_legs:
289
- filter_legs(result, args.filter_legs)
434
+ filter_legs(result, args.filter_legs, quarantined=quarantine_ids(args.repo))
290
435
  elif args.shell:
291
436
  print('mode\t' + result['mode'])
292
437
  print('harnesses\t' + ' '.join(result['selectedHarnesses']))
@@ -246,6 +246,12 @@ def _adjacent_pairs(regimes) -> dict:
246
246
  `now` with `status: "stale"` and appends a fresh regime starting at the
247
247
  analysis start, so the two are neither adjacent nor a rate transition. The
248
248
  status check is the second, independent guard.
249
+
250
+ #978 spec 4.5: a pair also requires one `fingerprint` and one
251
+ `coefficientEra`. Units-per-point from two eras measure two different
252
+ weightings, so their difference is a coefficient change and never a
253
+ metering-rate change. The comparison uses `.get()`, so a legacy pair in
254
+ which neither regime names an era keeps its treatment.
249
255
  """
250
256
  closed: dict = {}
251
257
  for regime in regimes:
@@ -266,6 +272,10 @@ def _adjacent_pairs(regimes) -> dict:
266
272
  predecessor = closed.get(start)
267
273
  if predecessor is None or predecessor is regime:
268
274
  continue
275
+ if predecessor.get("fingerprint") != regime.get("fingerprint") \
276
+ or predecessor.get("coefficientEra") \
277
+ != regime.get("coefficientEra"):
278
+ continue
269
279
  pairs[start] = (predecessor, regime)
270
280
  return pairs
271
281