@nextcommerce/campaigns-os 1.37.3 → 1.43.1

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 (76) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +708 -0
  3. package/README.md +44 -31
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  7. package/contracts/effects.v1.json +4887 -0
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1541 -0
  10. package/contracts/supported-surface.json +33 -12
  11. package/docs/build-packet.md +83 -22
  12. package/docs/campaigns-os-build-flow.md +2 -2
  13. package/docs/demo-preview.md +1 -1
  14. package/docs/diagnostics.md +7 -4
  15. package/docs/effects.md +350 -0
  16. package/docs/gateway-login.md +113 -0
  17. package/docs/local-setup.md +51 -0
  18. package/docs/migration-sidecar-bundle.md +6 -1
  19. package/docs/orientation-contract-reference.md +4 -1
  20. package/docs/progress-snapshots.md +9 -3
  21. package/docs/qa-and-test-orders.md +29 -13
  22. package/docs/readback.md +523 -0
  23. package/docs/runtime-readiness.md +1 -1
  24. package/docs/sdk-storage-compatibility.md +1 -1
  25. package/docs/skills-revision.md +364 -0
  26. package/docs/supported-surface.md +11 -3
  27. package/docs/versioning.md +8 -4
  28. package/package.json +10 -4
  29. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  30. package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
  31. package/schemas/campaign-spec.v4.schema.json +4 -0
  32. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  33. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  34. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  35. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  36. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  37. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  38. package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
  39. package/skills/campaign-readback-classification/SKILL.md +230 -0
  40. package/skills/campaign-run-evidence/SKILL.md +142 -0
  41. package/skills/contribution-intake/SKILL.md +85 -0
  42. package/skills/next-campaigns-build/SKILL.md +33 -12
  43. package/skills/next-campaigns-os/SKILL.md +59 -22
  44. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  45. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  46. package/skills/next-campaigns-polish/SKILL.md +43 -17
  47. package/skills/next-campaigns-qa/SKILL.md +53 -28
  48. package/skills.json +40 -7
  49. package/src/admin-transport.mjs +123 -0
  50. package/src/cli.mjs +1178 -270
  51. package/src/credential-store.mjs +183 -0
  52. package/src/deviation.mjs +3 -2
  53. package/src/diagnostic.mjs +4 -1
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/gate-actions.mjs +2 -2
  56. package/src/install-mode.mjs +17 -9
  57. package/src/lifecycle.mjs +96 -0
  58. package/src/login.mjs +152 -0
  59. package/src/package-install-fixture.mjs +3 -2
  60. package/src/polish-node.mjs +5 -2
  61. package/src/progress-node.mjs +3 -2
  62. package/src/progress.mjs +5 -3
  63. package/src/qa-node.mjs +105 -36
  64. package/src/qa-publish.mjs +112 -2
  65. package/src/qa-sidecar.mjs +2 -0
  66. package/src/qa-verdict-discovery.mjs +11 -0
  67. package/src/qa-verdict-publish.mjs +1 -0
  68. package/src/qa-verdict.mjs +8 -1
  69. package/src/readback.mjs +1937 -0
  70. package/src/remit.mjs +17 -3
  71. package/src/run-record-closeout.mjs +3 -4
  72. package/src/run-record.mjs +4 -0
  73. package/src/sidecar-bundle.mjs +21 -0
  74. package/src/spec-source-identity.mjs +44 -0
  75. package/src/stage-ledger.mjs +4 -1
  76. package/src/tooling-setup.mjs +160 -0
@@ -0,0 +1,211 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://nextcommerce.com/schemas/campaigns-os-effects.v1.schema.json",
4
+ "title": "Campaigns OS Declared Command Effects v1",
5
+ "description": "The shape of contracts/effects.v1.json: one row per supported invocation — a command, its subcommand, and the flag combination that changes what the invocation does to the world — stating what it writes, what it sends, the MCP-style annotations a tool face would carry, the effect tier, and the name of the node:test case that proves the row. The claim a row makes is two-sided and both sides are tested: nothing the row does not declare may change, and every declared effect whose `observed_in` names a condition must be seen in it. An effect the offline fixture cannot reach carries an empty `observed_in` and a `not_observed_reason`. Read this with docs/effects.md; scripts/check-effects.mjs enforces the parts a schema cannot (coverage of the supported CLI surface, and the existence of every named test case).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "contract_version", "vocabulary", "rows"],
9
+ "properties": {
10
+ "schema": {
11
+ "const": "campaigns-os-effects/v1",
12
+ "description": "A consumer should refuse a payload whose schema it does not recognize rather than reading the fields it happens to know."
13
+ },
14
+ "_note": { "type": "string" },
15
+ "contract_version": {
16
+ "type": "string",
17
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
18
+ "description": "The contract's own version, independent of the package's surface_version: adding a row is additive, changing a row's declared effect is not."
19
+ },
20
+ "vocabulary": {
21
+ "type": "object",
22
+ "description": "The words the rows use, defined once. A reader must not have to infer what `tier: \"C\"` or `{lifecycle-journal}` means from the rows that happen to use them.",
23
+ "additionalProperties": false,
24
+ "required": ["annotations", "tiers", "locations", "conditions", "effect_changing_flags", "test_scope", "observed_in", "preflight"],
25
+ "properties": {
26
+ "annotations": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["readOnlyHint", "destructiveHint", "openWorldHint", "idempotentHint"],
30
+ "properties": {
31
+ "readOnlyHint": { "type": "string" },
32
+ "destructiveHint": { "type": "string" },
33
+ "openWorldHint": { "type": "string" },
34
+ "idempotentHint": { "type": "string" }
35
+ }
36
+ },
37
+ "tiers": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": ["none", "A", "B", "C", "_ranking"],
41
+ "properties": {
42
+ "none": { "type": "string" },
43
+ "A": { "type": "string" },
44
+ "B": { "type": "string" },
45
+ "C": { "type": "string" },
46
+ "_ranking": { "type": "string" }
47
+ }
48
+ },
49
+ "locations": {
50
+ "type": "object",
51
+ "description": "The location tokens a row's write paths may use. A path pattern is relative to the token it opens with.",
52
+ "minProperties": 1,
53
+ "additionalProperties": { "type": "string" }
54
+ },
55
+ "conditions": {
56
+ "type": "object",
57
+ "description": "The five conditions every row is exercised under. The fifth, persisted_consent, is not a variant of the other four: it is the one that does not take the row's word for whether telemetry consent is on.",
58
+ "additionalProperties": false,
59
+ "required": ["no_session", "ambient_session", "stale_session", "lifecycle_log", "persisted_consent"],
60
+ "properties": {
61
+ "no_session": { "type": "string" },
62
+ "ambient_session": { "type": "string" },
63
+ "stale_session": { "type": "string" },
64
+ "lifecycle_log": { "type": "string" },
65
+ "persisted_consent": { "type": "string" }
66
+ }
67
+ },
68
+ "effect_changing_flags": {
69
+ "type": "array",
70
+ "description": "The flags that change what an invocation does to the world. Every one of them that appears on a CLI help usage line owes a row of its own; scripts/check-effects.mjs refuses the file when one does not have it. A flag that only changes the output shape (--json, --report) is deliberately absent.",
71
+ "minItems": 1,
72
+ "uniqueItems": true,
73
+ "items": { "type": "string", "pattern": "^--[a-z0-9-]+$" }
74
+ },
75
+ "test_scope": {
76
+ "type": "object",
77
+ "additionalProperties": false,
78
+ "required": ["full", "preflight"],
79
+ "properties": { "full": { "type": "string" }, "preflight": { "type": "string" } }
80
+ },
81
+ "observed_in": { "type": "string" },
82
+ "preflight": { "type": "string" }
83
+ }
84
+ },
85
+ "rows": {
86
+ "type": "array",
87
+ "minItems": 1,
88
+ "items": { "$ref": "#/$defs/row" }
89
+ }
90
+ },
91
+ "$defs": {
92
+ "condition": {
93
+ "enum": ["no_session", "ambient_session", "stale_session", "lifecycle_log", "persisted_consent"]
94
+ },
95
+ "observed_in": {
96
+ "type": "array",
97
+ "description": "The conditions in which the effect test MUST see this effect. Empty declares an effect the offline fixture cannot reach, and then `not_observed_reason` is required.",
98
+ "items": { "$ref": "#/$defs/condition" },
99
+ "uniqueItems": true
100
+ },
101
+ "row": {
102
+ "type": "object",
103
+ "additionalProperties": false,
104
+ "required": ["command", "subcommand", "flags", "annotations", "tier", "writes", "sends", "effect_test", "test_scope", "notes"],
105
+ "properties": {
106
+ "command": {
107
+ "type": "string",
108
+ "minLength": 1,
109
+ "description": "The top-level command, or the reserved `*refused*` for an invocation refused before its handler runs."
110
+ },
111
+ "subcommand": {
112
+ "description": "The subcommand as the help text spells it (`policy set` is one subcommand, not a subcommand and a flag), or null.",
113
+ "oneOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }]
114
+ },
115
+ "flags": {
116
+ "type": "array",
117
+ "description": "The effect-changing flag combination this row is about. Empty is the base form. Flags that do not change what the invocation does to the world (--json, --report) are deliberately absent.",
118
+ "items": { "type": "string", "pattern": "^--[a-z0-9-]+$" },
119
+ "uniqueItems": true
120
+ },
121
+ "annotations": {
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "required": ["readOnlyHint", "destructiveHint", "openWorldHint", "idempotentHint"],
125
+ "properties": {
126
+ "readOnlyHint": { "type": "boolean" },
127
+ "destructiveHint": { "type": "boolean" },
128
+ "openWorldHint": { "type": "boolean" },
129
+ "idempotentHint": { "type": "boolean" }
130
+ }
131
+ },
132
+ "tier": { "enum": ["none", "A", "B", "C"] },
133
+ "writes": { "type": "array", "items": { "$ref": "#/$defs/write" } },
134
+ "sends": { "type": "array", "items": { "$ref": "#/$defs/send" } },
135
+ "effect_test": {
136
+ "type": "string",
137
+ "minLength": 1,
138
+ "description": "The exact name of the node:test case in src/effects.test.mjs that proves this row. check-effects.mjs refuses a row whose case is not there."
139
+ },
140
+ "test_scope": { "enum": ["full", "preflight"] },
141
+ "preflight": { "$ref": "#/$defs/preflight" },
142
+ "notes": {
143
+ "type": "string",
144
+ "minLength": 1,
145
+ "description": "Why the row says what it says, in a consumer's terms. A preflight row states what it cannot reach offline and what the case proves instead."
146
+ }
147
+ },
148
+ "if": { "properties": { "test_scope": { "const": "preflight" } }, "required": ["test_scope"] },
149
+ "then": {
150
+ "description": "A preflight row declares its allowances. The converse — a `full` row that carries them — is reported by scripts/check-effects.mjs, which can say which row it is.",
151
+ "required": ["preflight"],
152
+ "properties": { "preflight": { "$ref": "#/$defs/preflight" } }
153
+ }
154
+ },
155
+ "preflight": {
156
+ "type": "object",
157
+ "description": "What the preflight itself may do, declared independently of the effects the row declares for the invocation that gets past it. Without this, a write declared for the success path ({home}/** for a minted credential) also licensed the refusal to write anywhere under the home directory, and a destination a loopback receiver only stands in for licensed any request path at all.",
158
+ "additionalProperties": false,
159
+ "required": ["may_write", "may_contact"],
160
+ "properties": {
161
+ "may_write": {
162
+ "type": "array",
163
+ "description": "The ONLY paths the preflight may write, each a location token or a path under one. A pattern spanning segments (**) is refused by scripts/check-effects.mjs: an allowance that licenses a subtree licenses the home directory the preflight must not touch.",
164
+ "uniqueItems": true,
165
+ "items": { "type": "string", "minLength": 1 }
166
+ },
167
+ "may_contact": {
168
+ "type": "array",
169
+ "description": "The exact request paths the loopback receiver may see, matched literally against the request path. Empty means the preflight contacts nothing.",
170
+ "uniqueItems": true,
171
+ "items": { "type": "string", "minLength": 1 }
172
+ }
173
+ }
174
+ },
175
+ "write": {
176
+ "type": "object",
177
+ "additionalProperties": false,
178
+ "required": ["path", "when", "observed_in"],
179
+ "properties": {
180
+ "path": {
181
+ "type": "string",
182
+ "minLength": 1,
183
+ "description": "A glob pattern (`*` within one segment, `**` across segments) opening with a location token from vocabulary.locations."
184
+ },
185
+ "when": { "type": "string", "minLength": 1 },
186
+ "observed_in": { "$ref": "#/$defs/observed_in" },
187
+ "not_observed_reason": { "type": "string", "minLength": 1 }
188
+ }
189
+ },
190
+ "send": {
191
+ "type": "object",
192
+ "additionalProperties": false,
193
+ "required": ["destination", "what", "when", "requires_consent", "observed_in"],
194
+ "properties": {
195
+ "destination": {
196
+ "type": "string",
197
+ "minLength": 1,
198
+ "description": "The endpoint, with the same location tokens. A destination of the form `{proxy-base}/<path>` pins the request path down, so the test can assert the declared destination IS the one contacted."
199
+ },
200
+ "what": { "type": "string", "minLength": 1 },
201
+ "when": { "type": "string", "minLength": 1 },
202
+ "requires_consent": {
203
+ "type": "boolean",
204
+ "description": "True when Run Telemetry consent gates the send. The effect test runs such a row with consent ON and a loopback receiver, so 'nothing was sent' can never be an artefact of consent being off."
205
+ },
206
+ "observed_in": { "$ref": "#/$defs/observed_in" },
207
+ "not_observed_reason": { "type": "string", "minLength": 1 }
208
+ }
209
+ }
210
+ }
211
+ }
@@ -71,6 +71,7 @@
71
71
  "build_fingerprint_algorithm"
72
72
  ],
73
73
  "properties": {
74
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$" },
74
75
  "map_id": {
75
76
  "type": [
76
77
  "string",
@@ -29,6 +29,7 @@
29
29
  "description": "Same literal as the full verdict — the sidecar is the same contract projected, not a second schema lineage."
30
30
  },
31
31
  "run_id": { "type": "string", "minLength": 1 },
32
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
32
33
  "campaign_slug": {
33
34
  "type": "string",
34
35
  "minLength": 1,
@@ -29,6 +29,7 @@
29
29
  "minLength": 1,
30
30
  "description": "The QA run identity, also the verdict filename under qa-output/<map-id>/. The portal receiver additionally restricts it to [A-Za-z0-9_-]{1,64}."
31
31
  },
32
+ "local_spec_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions." },
32
33
  "campaign_slug": {
33
34
  "type": "string",
34
35
  "minLength": 1,
@@ -0,0 +1,267 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://nextcommerce.com/schemas/campaigns-os-readback.v2.schema.json",
4
+ "title": "Campaigns OS Run-Artifact Readback v2",
5
+ "description": "The machine-readable projection `campaigns-os readback <target> --json` writes to stdout. It serializes exactly what the rendered text view computes over a run's already-emitted artifacts (Build Packet, doctor output, build context, assembly report, QA verdict, findings export) and adds no interpretation the text view does not also carry. The readback is read-only: it writes nothing under the target, starts no process, touches no network, and records no lifecycle entry. v2 differs from v1 in one field's meaning: staleness is assessed PER ARTIFACT, and the aggregate `stale` is true when ANY loaded artifact is stale, where v1 compared only the newest artifact and so reported a set fresh whenever its newest member was. Field semantics and the exact `clean` rule live in docs/readback.md; that document and this schema move together. A consumer that recognizes v2 accepts and preserves unknown additive properties without interpreting them.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "required": [
9
+ "schema_version",
10
+ "artifacts",
11
+ "packet_selection",
12
+ "staleness",
13
+ "doctor",
14
+ "skip_cascades",
15
+ "divergences",
16
+ "clean"
17
+ ],
18
+ "properties": {
19
+ "schema_version": {
20
+ "const": "campaigns-os-readback/v2",
21
+ "description": "A consumer should refuse a payload whose schema_version it does not recognize rather than reading the fields it happens to know."
22
+ },
23
+ "artifacts": {
24
+ "type": "array",
25
+ "description": "One record per artifact the readback projected, in the fixed render order (packet, doctor, context, report, qa_verdict, findings), restricted to the keys the caller asked for. The artifact's own parsed contents are deliberately absent: a consumer that wants an artifact's payload reads that artifact, so this projection never becomes an unversioned mirror of every upstream schema.",
26
+ "items": { "$ref": "#/$defs/artifact_row" }
27
+ },
28
+ "packet_selection": {
29
+ "description": "How the projected Build Packet was chosen, or null when a programmatic caller built a payload without a selection record. The CLI always supplies one.",
30
+ "oneOf": [{ "$ref": "#/$defs/packet_selection" }, { "type": "null" }]
31
+ },
32
+ "staleness": {
33
+ "description": "The per-artifact freshness assessment, or null when a programmatic caller built a payload without one. The CLI always supplies one.",
34
+ "oneOf": [{ "$ref": "#/$defs/staleness" }, { "type": "null" }]
35
+ },
36
+ "doctor": { "$ref": "#/$defs/doctor_summary" },
37
+ "skip_cascades": {
38
+ "type": "array",
39
+ "description": "Skipped QA assertions grouped by the failure that blocked them, derived from each skipped assertion's evidence.blocked_by. One record per distinct blocker, in first-seen order. Empty when no QA verdict loaded or none of its assertions were skipped. The grouping is the readback's own projection layer.",
40
+ "items": { "$ref": "#/$defs/skip_cascade" }
41
+ },
42
+ "divergences": {
43
+ "type": "array",
44
+ "description": "Where two artifacts record different states for one stage: the assembly report calls a stage completed while the QA verdict fails an assertion whose id is namespaced to that stage. One record per stage, in the order the failures were seen. The readback reports the disagreement and does not adjudicate it. This too is the readback's own layer.",
45
+ "items": { "$ref": "#/$defs/divergence" }
46
+ },
47
+ "clean": {
48
+ "type": "boolean",
49
+ "description": "True only when every artifact the readback found is loaded or absent (never unreadable or unrecognized), staleness is computable and NO loaded artifact is stale, staleness.unparseable_keys is empty (no loaded artifact recorded an age the readback could not read), divergences is empty, and doctor.error_count is zero. A readback-integrity flag, not a verdict: a blocked QA verdict whose artifacts all read cleanly is still clean, because the readback saw exactly what Campaigns OS recorded. docs/readback.md states the rule and its limits."
50
+ }
51
+ },
52
+ "$defs": {
53
+ "artifact_key": {
54
+ "type": "string",
55
+ "enum": ["packet", "doctor", "context", "report", "qa_verdict", "findings"]
56
+ },
57
+ "utc_timestamp": {
58
+ "type": "string",
59
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$",
60
+ "description": "ISO-8601 UTC at second precision with a Z suffix, rendered by the same formatter the text view uses so both modes name the same instant identically."
61
+ },
62
+ "artifact_row": {
63
+ "type": "object",
64
+ "additionalProperties": true,
65
+ "required": ["key", "path", "state", "detail"],
66
+ "properties": {
67
+ "key": { "$ref": "#/$defs/artifact_key" },
68
+ "path": {
69
+ "type": "string",
70
+ "minLength": 1,
71
+ "description": "The path the readback read, as resolved from the target root, a --<artifact> override, or — for packet — Build Packet discovery. --example reports the bundled sample's paths relative to the package root so the sample's output is identical wherever the package is installed."
72
+ },
73
+ "state": {
74
+ "type": "string",
75
+ "enum": ["loaded", "absent", "unreadable", "unrecognized"],
76
+ "description": "loaded: read and recognized. absent: no file at that path. unreadable: the bytes could not be read or parsed (over the 32 MiB read bound, not UTF-8, not JSON). unrecognized: parsed, but not a shape this readback projects."
77
+ },
78
+ "detail": {
79
+ "type": "string",
80
+ "description": "The readback's explanation for a non-loaded state, or — for a loaded artifact named in staleness.unparseable_keys — the shape of the generated_at value that did not parse, so a consumer reading artifacts alone can see that this artifact's age was never established. The value itself is deliberately not reproduced here. The empty string for every other loaded artifact and for an absent one."
81
+ }
82
+ }
83
+ },
84
+ "packet_selection": {
85
+ "type": "object",
86
+ "additionalProperties": true,
87
+ "required": ["mode", "signal", "selected", "candidates_considered", "rejected"],
88
+ "properties": {
89
+ "mode": {
90
+ "type": "string",
91
+ "enum": ["explicit", "default", "discovered"],
92
+ "description": "explicit: the caller passed --packet. default: the chosen packet is the default-named campaign-runtime.build.json, including a target with no packet at all, where the default path is still the path reported absent. discovered: freshness selected a suffixed packet."
93
+ },
94
+ "signal": {
95
+ "type": "string",
96
+ "enum": ["explicit", "sole_candidate", "generated_at", "none"],
97
+ "description": "What decided it. Freshness is the packet's own recorded generated_at, never the file's modification time: an mtime is rewritten by a clone, a checkout, or a copy without any run having recorded anything, so content-only selection keeps discovery a pure function of the bytes on disk."
98
+ },
99
+ "selected": {
100
+ "description": "The file name discovery chose, or null when no discovery chose one (the explicit and none signals). The full path is the packet row's path in artifacts.",
101
+ "type": ["string", "null"]
102
+ },
103
+ "candidates_considered": {
104
+ "type": "array",
105
+ "items": { "type": "string", "minLength": 1 },
106
+ "description": "The file names of the valid candidates discovery compared, default-first then name order. Empty for explicit mode, where no discovery runs."
107
+ },
108
+ "rejected": {
109
+ "type": "array",
110
+ "items": { "$ref": "#/$defs/rejected_candidate" },
111
+ "description": "Candidates discovery skipped. Root-level files matching the packet naming land here when they did not parse, were not an object, or did not declare a recognized packet schema_version. When no valid root candidate exists, a packet sitting only at .campaign-runtime/campaign-runtime.build.json is recorded here too: that sidecar location is not a discovery candidate, and the reason tells the caller to pass --packet."
112
+ }
113
+ }
114
+ },
115
+ "rejected_candidate": {
116
+ "type": "object",
117
+ "additionalProperties": true,
118
+ "required": ["path", "reason"],
119
+ "properties": {
120
+ "path": { "type": "string", "minLength": 1 },
121
+ "reason": { "type": "string", "minLength": 1 }
122
+ }
123
+ },
124
+ "staleness": {
125
+ "type": "object",
126
+ "additionalProperties": true,
127
+ "required": [
128
+ "computable",
129
+ "stale",
130
+ "stale_keys",
131
+ "unparseable_keys",
132
+ "artifacts",
133
+ "newest_key",
134
+ "head_time",
135
+ "head_detail",
136
+ "artifact_times"
137
+ ],
138
+ "properties": {
139
+ "computable": {
140
+ "type": "boolean",
141
+ "description": "True when at least one loaded artifact carried a parseable generated_at AND the checkout's HEAD reflog was readable."
142
+ },
143
+ "stale": {
144
+ "type": "boolean",
145
+ "description": "True when ANY loaded artifact's generated_at predates the last recorded HEAD movement. This is the v1 -> v2 change of meaning: v1 compared only the newest artifact, so one regenerated artifact reported the whole set fresh. Always false when computable is false; absence of the signal is not evidence of freshness."
146
+ },
147
+ "stale_keys": {
148
+ "type": "array",
149
+ "items": { "$ref": "#/$defs/artifact_key" },
150
+ "description": "The stale artifact keys, in the fixed render order. Empty when nothing is stale or the comparison is not computable."
151
+ },
152
+ "unparseable_keys": {
153
+ "type": "array",
154
+ "items": { "$ref": "#/$defs/artifact_key" },
155
+ "description": "The loaded artifacts that RECORDED a generated_at this readback could not parse, in the fixed render order. Their age was never established — they are neither fresh nor stale, they are absent from artifacts and artifact_times, and clean is false while this list is non-empty, because an unknown age must never read as a fresh one. Each one's artifact row carries the shape of the refused value in its detail. An artifact that carries NO generated_at key at all is a different case: it recorded no age to check, so it is absent from this list and is not by itself unclean (though with no other artifact carrying one, the comparison is not computable). Independent of computable and stale, which keep their meanings: a set whose only comparable artifact is fresh still reports stale false."
156
+ },
157
+ "artifacts": {
158
+ "type": "object",
159
+ "additionalProperties": { "$ref": "#/$defs/artifact_freshness" },
160
+ "description": "Every loaded artifact that carried a parseable generated_at, keyed by artifact key, in render order. An artifact with no parseable generated_at is neither fresh nor stale and is absent from this map; if it is the only artifact, the assessment is not computable. Where it recorded a generated_at that did not parse, unparseable_keys names it."
161
+ },
162
+ "newest_key": {
163
+ "description": "The artifact key holding the newest generated_at, or null when not computable. Information only in v2: it no longer decides the aggregate.",
164
+ "oneOf": [{ "$ref": "#/$defs/artifact_key" }, { "type": "null" }]
165
+ },
166
+ "head_time": {
167
+ "description": "The last recorded HEAD movement, or null when the reflog gave no usable time.",
168
+ "oneOf": [{ "$ref": "#/$defs/utc_timestamp" }, { "type": "null" }]
169
+ },
170
+ "head_detail": {
171
+ "type": "string",
172
+ "description": "Why head_time is null; the empty string when it is not."
173
+ },
174
+ "artifact_times": {
175
+ "type": "object",
176
+ "additionalProperties": { "$ref": "#/$defs/utc_timestamp" },
177
+ "description": "Every loaded artifact's parseable generated_at, keyed by artifact key. Carried unchanged from v1 for consumers that already read it; artifacts carries the same instants plus each one's verdict."
178
+ }
179
+ }
180
+ },
181
+ "artifact_freshness": {
182
+ "type": "object",
183
+ "additionalProperties": true,
184
+ "required": ["generated_at", "stale"],
185
+ "properties": {
186
+ "generated_at": { "$ref": "#/$defs/utc_timestamp" },
187
+ "stale": {
188
+ "type": "boolean",
189
+ "description": "True when the last recorded HEAD movement is later than this artifact's generated_at. False when there is no HEAD signal to compare against."
190
+ }
191
+ }
192
+ },
193
+ "doctor_summary": {
194
+ "type": "object",
195
+ "additionalProperties": true,
196
+ "required": ["present", "status", "error_count", "warning_count", "warning_groups"],
197
+ "properties": {
198
+ "present": {
199
+ "type": "boolean",
200
+ "description": "Whether a doctor output loaded. When false, status is null and both counts are 0: those zeros record an absence, not an observation."
201
+ },
202
+ "status": {
203
+ "type": ["string", "null"],
204
+ "description": "Doctor's own status string, unreinterpreted."
205
+ },
206
+ "error_count": {
207
+ "type": "integer",
208
+ "minimum": 0,
209
+ "description": "The length of doctor's errors list (0 when the field is missing or not a list)."
210
+ },
211
+ "warning_count": {
212
+ "type": "integer",
213
+ "minimum": 0,
214
+ "description": "The total number of object-shaped warnings grouped below."
215
+ },
216
+ "warning_groups": {
217
+ "type": "object",
218
+ "additionalProperties": true,
219
+ "required": ["contract-static", "repo-observed"],
220
+ "description": "The readback's own two-way grouping of doctor warnings, carrying each warning as doctor wrote it. The labels are the readback's projection layer, not doctor's vocabulary.",
221
+ "properties": {
222
+ "contract-static": {
223
+ "type": "array",
224
+ "items": { "type": "object" },
225
+ "description": "Codes under the frontmatter.* prefixes. These restate the template family's shared frontmatter contract and repeat verbatim on every doctor pass while that contract is in force. Their persistence does not mean a flagged value is still unfixed, and their disappearance is not how a fix is confirmed. A gate must not treat these as repository state."
226
+ },
227
+ "repo-observed": {
228
+ "type": "array",
229
+ "items": { "type": "object" },
230
+ "description": "Every other code: not in the contract-static table, so its message reflects this repository, spec, or build as doctor saw it."
231
+ }
232
+ }
233
+ }
234
+ }
235
+ },
236
+ "skip_cascade": {
237
+ "type": "object",
238
+ "additionalProperties": true,
239
+ "required": ["blocked_by", "families"],
240
+ "properties": {
241
+ "blocked_by": {
242
+ "type": "string",
243
+ "minLength": 1,
244
+ "description": "The recorded blocking assertion, or the literal \"(no blocked_by recorded)\" when the verdict named none."
245
+ },
246
+ "families": {
247
+ "type": "array",
248
+ "items": { "type": "string" },
249
+ "description": "The skipped assertions' families, falling back to the assertion id and then to \"(unnamed)\"."
250
+ }
251
+ }
252
+ },
253
+ "divergence": {
254
+ "type": "object",
255
+ "additionalProperties": true,
256
+ "required": ["stage", "assertion_ids"],
257
+ "properties": {
258
+ "stage": { "type": "string", "minLength": 1 },
259
+ "assertion_ids": {
260
+ "type": "array",
261
+ "items": { "type": "string" },
262
+ "description": "The failing QA assertion ids attributed to that stage."
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
@@ -155,6 +155,7 @@
155
155
  "additionalProperties": false,
156
156
  "description": "Best-effort run identity — the join keys that make telemetry useful. Missing identity never blocks capture.",
157
157
  "properties": {
158
+ "local_spec_id": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "Stable repository-owned CampaignSpec identity; never a saved Map ID. Keep unchanged across local revisions. Null or omitted means no local identity." },
158
159
  "map_id": { "type": ["string", "null"] },
159
160
  "campaign_slug": { "type": ["string", "null"] },
160
161
  "template_family": { "type": ["string", "null"] },