@momentiq/dark-factory-cli 2.10.0 → 2.11.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.
@@ -1068,19 +1068,33 @@ def _declared_refs(trailers: "Trailers") -> set[str]:
1068
1068
  return refs
1069
1069
 
1070
1070
 
1071
- def validate_objectives(repo_root: Path, trailers: "Trailers") -> list[str]:
1071
+ def validate_objectives(
1072
+ repo_root: Path, trailers: "Trailers", changed_files: Iterable[str]
1073
+ ) -> list[str]:
1072
1074
  """Validate .darkfactory/objectives.yaml against PR context. Empty list = ok.
1073
1075
 
1074
- v1 checks: id format, source linked by a PR trailer, attestedBy route exists.
1075
- NO coverage check (the `enforced` flag is the future ratchet). Absent manifest
1076
- is a no-op objectives are optional in v1.
1077
-
1078
- Uses a deferred ``import yaml`` so that a missing pyyaml installation only
1079
- errors when a repo actually ships ``.darkfactory/objectives.yaml`` (the
1080
- absent-manifest path is a no-op and never imports yaml). This preserves the
1081
- validator's dependency-light boot on CI environments that have not installed
1082
- pyyaml.
1076
+ **Validated ONLY when this PR authors/edits the manifest** (it appears in the
1077
+ PR's changed files). A committed manifest from an earlier PR must NOT be
1078
+ re-validated against an unrelated later PR's trailers that was the
1079
+ stale-manifest gate-break (momentiq-ai/dark-factory#207). Combined with
1080
+ per-PR authoring (the agent drafts the manifest at PR-open), each PR's
1081
+ objectives are checked against that PR's own trailers and nothing else.
1082
+
1083
+ Structural checks mirror the TS ``parseObjectivesManifest`` contract (id
1084
+ format, id↔source consistency, non-empty text, ``enforced`` boolean, and the
1085
+ ``attestedBy`` binding shapes) so the Python gate is not a looser subset that
1086
+ can silently drift from the TS parser. The PR-context checks (source linked
1087
+ by a trailer, ``route`` binding exists in config) are gate-only — they need
1088
+ PR state the pure parser doesn't have. NO coverage check (the ``enforced``
1089
+ flag is the future ratchet).
1090
+
1091
+ Uses a deferred ``import yaml`` so a missing pyyaml installation only errors
1092
+ when a repo actually ships the manifest (the skip/absent paths never import
1093
+ yaml), preserving the validator's dependency-light boot.
1083
1094
  """
1095
+ # Gate: only author-time validation. PRs that don't touch the manifest skip.
1096
+ if OBJECTIVES_MANIFEST_PATH not in set(changed_files):
1097
+ return []
1084
1098
  manifest_path = repo_root / OBJECTIVES_MANIFEST_PATH
1085
1099
  if not manifest_path.exists():
1086
1100
  return []
@@ -1096,6 +1110,11 @@ def validate_objectives(repo_root: Path, trailers: "Trailers") -> list[str]:
1096
1110
  return [f"{OBJECTIVES_MANIFEST_PATH}: invalid YAML — {exc}"]
1097
1111
  if not isinstance(data, dict):
1098
1112
  return [f"{OBJECTIVES_MANIFEST_PATH}: top-level must be a mapping"]
1113
+ if data.get("schemaVersion") != 1:
1114
+ return [
1115
+ f"{OBJECTIVES_MANIFEST_PATH}: schemaVersion must be 1, got "
1116
+ f"{data.get('schemaVersion')!r}"
1117
+ ]
1099
1118
  objectives = data.get("objectives")
1100
1119
  if not isinstance(objectives, list):
1101
1120
  return [f"{OBJECTIVES_MANIFEST_PATH}: 'objectives' must be a list"]
@@ -1113,31 +1132,72 @@ def validate_objectives(repo_root: Path, trailers: "Trailers") -> list[str]:
1113
1132
  errors.append(
1114
1133
  f"{loc}.id: expected 'cycle<N>#ec<k>' or 'issue<N>#ac<k>', got {oid!r}"
1115
1134
  )
1135
+ text = obj.get("text")
1136
+ if not isinstance(text, str) or not text:
1137
+ errors.append(f"{loc}.text: expected a non-empty string")
1138
+ if not isinstance(obj.get("enforced"), bool):
1139
+ errors.append(f"{loc}.enforced: expected a boolean")
1116
1140
  source = obj.get("source")
1117
1141
  if not isinstance(source, dict):
1118
1142
  errors.append(f"{loc}.source: expected a mapping, got {source!r}")
1119
1143
  continue
1120
1144
  kind, ref = source.get("kind"), source.get("ref")
1121
- # Normalize refs to match _declared_refs: cycle ids go through
1122
- # normalize_cycle_id (handles dotted ids and "Cycle N.N" prefixes);
1123
- # issue refs strip a leading "#" so "1234" and "#1234" compare equal.
1124
- if kind == "cycle" and isinstance(ref, str):
1125
- normalized_ref = normalize_cycle_id(ref)
1126
- elif kind == "issue" and isinstance(ref, str):
1127
- normalized_ref = re.sub(r'^#', '', ref)
1128
- else:
1129
- normalized_ref = ref
1130
- if f"{kind}:{normalized_ref}" not in declared:
1131
- errors.append(
1132
- f"{loc}.source: {kind} {ref!r} is not linked by any Cycle:/Closes #N trailer on this PR"
1133
- )
1134
- for j, binding in enumerate(obj.get("attestedBy") or []):
1135
- if isinstance(binding, dict) and binding.get("kind") == "route":
1145
+ if kind not in ("cycle", "issue"):
1146
+ errors.append(f"{loc}.source.kind: expected 'cycle' | 'issue', got {kind!r}")
1147
+ if not isinstance(ref, str) or not ref:
1148
+ errors.append(f"{loc}.source.ref: expected a non-empty string")
1149
+ # id↔source consistency (mirrors the TS parseObjective invariant): the id
1150
+ # must be namespaced by its source, e.g. cycle21#ec1 ↔ {cycle, "21"}.
1151
+ if isinstance(oid, str) and isinstance(kind, str) and isinstance(ref, str):
1152
+ ref_num = re.sub(r"^#", "", ref) if kind == "issue" else ref
1153
+ if not oid.startswith(f"{kind}{ref_num}#"):
1154
+ errors.append(
1155
+ f"{loc}.id: {oid!r} is inconsistent with source "
1156
+ f"{{kind: {kind}, ref: {ref!r}}}"
1157
+ )
1158
+ # PR-context: the source must be linked by one of this PR's trailers.
1159
+ # Only run when kind/ref are themselves valid — otherwise their own
1160
+ # errors above already fired, and a "not linked" message here would be
1161
+ # misleading noise the TS parser wouldn't surface. Normalize to match
1162
+ # _declared_refs (cycle via normalize_cycle_id; issue strips a leading "#").
1163
+ if kind in ("cycle", "issue") and isinstance(ref, str):
1164
+ normalized_ref = normalize_cycle_id(ref) if kind == "cycle" else re.sub(r'^#', '', ref)
1165
+ if f"{kind}:{normalized_ref}" not in declared:
1166
+ errors.append(
1167
+ f"{loc}.source: {kind} {ref!r} is not linked by any Cycle:/Closes #N trailer on this PR"
1168
+ )
1169
+ # attestedBy: structural validation of each binding (kind + required
1170
+ # field), plus route-existence (PR context) for route bindings.
1171
+ bindings = obj.get("attestedBy")
1172
+ if not isinstance(bindings, list):
1173
+ errors.append(f"{loc}.attestedBy: expected a list")
1174
+ continue
1175
+ for j, binding in enumerate(bindings):
1176
+ bloc = f"{loc}.attestedBy[{j}]"
1177
+ if not isinstance(binding, dict):
1178
+ errors.append(f"{bloc}: expected a mapping")
1179
+ continue
1180
+ bkind = binding.get("kind")
1181
+ if bkind == "route":
1136
1182
  rid = binding.get("routeId")
1137
- if rid not in route_ids:
1183
+ if not isinstance(rid, str) or not rid:
1184
+ errors.append(f"{bloc}.routeId: expected a non-empty string")
1185
+ elif rid not in route_ids:
1138
1186
  errors.append(
1139
- f"{loc}.attestedBy[{j}].routeId: {rid!r} is not a verificationRoute in .agent-review/config.json"
1187
+ f"{bloc}.routeId: {rid!r} is not a verificationRoute in .agent-review/config.json"
1140
1188
  )
1189
+ elif bkind == "critic":
1190
+ cid = binding.get("criticId")
1191
+ if not isinstance(cid, str) or not cid:
1192
+ errors.append(f"{bloc}.criticId: expected a non-empty string")
1193
+ elif bkind == "test":
1194
+ tref = binding.get("ref")
1195
+ if not isinstance(tref, str) or not tref:
1196
+ errors.append(f"{bloc}.ref: expected a non-empty string")
1197
+ else:
1198
+ errors.append(
1199
+ f"{bloc}.kind: expected 'route' | 'critic' | 'test', got {bkind!r}"
1200
+ )
1141
1201
  return errors
1142
1202
 
1143
1203
 
@@ -1162,7 +1222,8 @@ def validate(
1162
1222
  return errors
1163
1223
 
1164
1224
  trailers = parse_trailers(body)
1165
- errors.extend(validate_objectives(REPO_ROOT, trailers))
1225
+ changed_files = list(changed_files)
1226
+ errors.extend(validate_objectives(REPO_ROOT, trailers, changed_files))
1166
1227
  plan = is_plan_pr(title, labels_list, changed_files)
1167
1228
  pr_type = "plan-pr" if plan else "code-pr"
1168
1229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentiq/dark-factory-cli",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Dark Factory OSS CLI — multi-vendor adversarial critic orchestration (Cursor, Codex, Gemini, Grok) with min-complete-quorum aggregation and trusted-surface rebind",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://momentiq.ai/dark-factory",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: verify
3
- description: Use to PRODUCE evidence-gated validation-route evidence for a change before pushing — run the change-appropriate verification route(s) (terraform validate, helm lint+template, docker build, the touched service's targeted tests, the UI/Playwright route) and write per-SHA, diffHash-bound QualityGateEvidence the local pre-push gate (df gate-push / enforceVerificationRoutes) asserts. Use when asked to "verify a change", "produce route evidence", a routed push is blocked with verification_route_missing / verification_route_failed, or before pushing a commit that touches a routed surface (infra/terraform, deploy/helm, a Dockerfile, services/*/src, web/** or *.tsx).
3
+ description: Use to PRODUCE evidence-gated validation-route evidence for a change before pushing — run the change-appropriate verification route(s) (terraform validate, helm lint+template, docker build, the touched service's targeted tests, the UI/Playwright route) and write per-SHA, diffHash-bound QualityGateEvidence the local pre-push gate (df gate-push / enforceVerificationRoutes) asserts. Use when asked to "verify a change", "produce route evidence", a routed push is blocked with verification_route_missing / verification_route_failed, or before pushing a commit that touches a routed surface (infra/terraform, deploy/helm, a Dockerfile, services/*/src, web/** or *.tsx). ALSO use at CLOSEOUT — when a change declares objectives in .darkfactory/objectives.yaml, run `df prove` to bind that evidence to the objectives and "declare victory with proof" (a proven/pending/failed readout) instead of asserting "done".
4
4
  ---
5
5
 
6
6
  # /verify — evidence-gated validation routes
@@ -98,3 +98,31 @@ CLI. Copy the reference files from the `@momentiq/dark-factory-cli` package
98
98
  (routes + required headings), wire an optional auth storage-state for protected
99
99
  surfaces, and arm the `playwright` route's `command`. The full walkthrough is in
100
100
  `docs/guides/ui-verification-route.md`.
101
+
102
+ ## Closeout — declare victory with proof, not "done"
103
+
104
+ Producing evidence is half the loop; the other half is **proving the objectives
105
+ it attests**. When a change declares objectives in `.darkfactory/objectives.yaml`,
106
+ the closeout step is **`df prove`** — it joins each objective against the evidence
107
+ (`df verify` route exit codes + `df review` critic verdicts) and reports, per
108
+ objective, **proven / pending / failed**. An agent's final turn runs `df prove`
109
+ (or the `df_prove` MCP tool) and **cites that readout as the completion claim** —
110
+ proof derived from diffHash-bound artifacts — rather than asserting "done".
111
+
112
+ ```bash
113
+ df prove # human readout: per-objective proven/pending/failed + bindings
114
+ df prove --json # the BoundProofRecord (for agents / the df_prove MCP tool)
115
+ df prove --strict # exit 1 if ANY objective is not proven (the gate form)
116
+ ```
117
+
118
+ - **`pending` is honest, not a failure.** A `critic` binding is `pending` until the
119
+ fleet runs on HEAD (run `df review` or wait for CI); a `route` binding you can
120
+ satisfy now with `df verify`. Only `failed` (evidence is negative) is a problem.
121
+ - **Trust boundary.** This readout is *agent-attested, evidence-backed* — stronger
122
+ than free-text "done" (every status is derived, not asserted), but not yet
123
+ independent verification (that closes with runner-attested evidence + the
124
+ source-criterion ratchet).
125
+ - **The ratchet.** v1 is informational (`enforced: false` — `df prove` exits 0 and
126
+ surfaces pending/failed). Flip an objective to `enforced: true`, or run
127
+ `df prove --strict` (e.g. a `.husky/pre-push` step — see CONSUMER-ADOPTION §5),
128
+ to turn unproven objectives into a hard block with no code change.