@momentiq/dark-factory-cli 3.0.0 → 3.0.2

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.
package/README.md CHANGED
@@ -42,8 +42,8 @@ relevant) as `df` subcommands:
42
42
 
43
43
  The package also ships five reusable GitHub Actions workflows
44
44
  (`.github/workflows/*.yml`) that consumers wire up via `uses:`. See the
45
- [root README](../../README.md#reusable-workflows) for the consumer wiring
46
- pattern.
45
+ [root README](https://github.com/momentiq-ai/dark-factory/blob/main/README.md#reusable-workflows)
46
+ for the consumer wiring pattern.
47
47
 
48
48
  ## Status
49
49
 
@@ -163,22 +163,19 @@ used when:
163
163
  hooks locally.
164
164
  - A hook bypass landed and the CI gate needs to re-evaluate.
165
165
  - The developer hasn't logged in to a vendor CLI yet
166
- (`cursor login` / `codex login` / Claude desktop OAuth).
166
+ (`cursor-agent login` / `codex login` / Claude desktop OAuth).
167
167
 
168
168
  ### `.agent-review/config.json` — the profile that pins subscription auth
169
169
 
170
170
  ```json
171
171
  {
172
172
  "version": 2,
173
- "critics": [
174
- { "id": "cursor-local-chief-engineer", "adapter": "cursor-sdk", ... },
175
- { "id": "codex-local-chief-engineer", "adapter": "codex-sdk", ... }
176
- ],
177
173
  "profiles": {
178
174
  "local": {
179
- "criticIds": ["cursor-local-chief-engineer", "codex-local-chief-engineer"],
180
- "quorum": 1,
175
+ "criticIds": ["cursor-cli-chief-engineer", "codex-local-chief-engineer"],
176
+ "quorum": 2,
181
177
  "auth": {
178
+ "cursor-cli-chief-engineer": "chatgpt",
182
179
  "codex-local-chief-engineer": "chatgpt"
183
180
  }
184
181
  },
@@ -193,10 +190,22 @@ used when:
193
190
  }
194
191
  ```
195
192
 
196
- The `local` profile pins `codex` to `"chatgpt"` — the Codex SDK will use
197
- `~/.codex/auth.json` (from `codex login`) and **NOT** fall back to
198
- `CODEX_API_KEY` even if it's set in env. This is the firewall against
199
- accidental API-token billing.
193
+ The block shows the **`profiles`** map — the local/cloud profile wiring. The
194
+ ids it references (e.g. `cursor-cli-chief-engineer`) are critics defined in the
195
+ config's top-level `critics` array each with `id`, `name`, `adapter`,
196
+ `required`, `runtime`, and `model` — omitted here to keep the focus on the auth
197
+ wiring. Step 2 of the setup checklist below points to the complete, schema-valid
198
+ file.
199
+
200
+ The `local` profile wires Cursor to the **`cursor-cli`** subscription
201
+ adapter (`cursor-agent login`, no API key) and pins both
202
+ `cursor-cli-chief-engineer` and `codex-local-chief-engineer` to
203
+ `"chatgpt"` — each vendor CLI uses its logged-in subscription session
204
+ (`~/.codex/auth.json` for Codex) and does **NOT** fall back to
205
+ `CURSOR_API_KEY` / `CODEX_API_KEY` even if set in env. This is the
206
+ firewall against accidental API-token billing. The `cloud` profile
207
+ deliberately keeps the API-key `cursor-sdk` critic — CI runners have no
208
+ subscription login.
200
209
 
201
210
  `df doctor --profile local` validates the configured subscription source
202
211
  is reachable. Run it after first-time setup.
@@ -253,10 +262,19 @@ API — see `packages/cli/src/doppler-bootstrap.ts` for the
253
262
  ### First-time setup checklist
254
263
 
255
264
  1. `npm install --ignore-scripts @momentiq/dark-factory-cli`
256
- 2. Add `.agent-review/config.json` with the `local` profile (above).
265
+ 2. Create `.agent-review/config.json`: copy the canonical
266
+ [`.agent-review/config.json`](https://github.com/momentiq-ai/dark-factory/blob/main/.agent-review/config.json)
267
+ from the `momentiq-ai/dark-factory` repo and adjust it to your source layout
268
+ (see the [consumer-adoption guide](https://github.com/momentiq-ai/dark-factory/blob/main/docs/CONSUMER-ADOPTION.md)
269
+ §6). To avoid drift, swap `blob/main` in those links for the tag matching
270
+ your installed CLI version (e.g. `blob/dark-factory-cli-v3.0.1`); `df doctor`
271
+ flags a mismatch. No API key needed — it pins the `local`
272
+ profile to your Cursor / Codex subscription logins. (Optional: with an
273
+ `ANTHROPIC_API_KEY` set, `df onboard --apply --profile local` generates a
274
+ repo-tailored config for you.)
257
275
  3. Add `.husky/post-commit` + `.husky/pre-push` (samples above).
258
276
  4. `git config --local core.hooksPath .husky`
259
- 5. `cursor login` / `codex login` (or Claude desktop OAuth) on the workstation.
277
+ 5. `cursor-agent login` / `codex login` (or Claude desktop OAuth) on the workstation.
260
278
  6. Run `df doctor --profile local` — should report all OK.
261
279
  7. Commit something — observe `.git/agent-reviews/<sha>.json` arrives.
262
280
  8. (Optional) Set up CI with `CURSOR_API_KEY` / `CODEX_API_KEY` /
@@ -59,7 +59,11 @@ Environment:
59
59
 
60
60
  Outputs:
61
61
 
62
- exit 0 — spec == live, no divergence;
62
+ exit 0 — spec == live, no divergence. May still print advisory
63
+ ``[branch-protection-audit] WARNING: ...`` lines above the OK
64
+ banner — e.g. a required EXTERNAL reusable-workflow context whose
65
+ inner job name cannot be resolved offline is skipped-with-warning
66
+ (issue #341). Warnings never change the exit code;
63
67
  exit 1 — divergence (structured field-by-field report on stdout);
64
68
  exit 2 — usage / configuration error (e.g., spec.yaml missing).
65
69
  """
@@ -124,20 +128,33 @@ class Divergence:
124
128
  @dataclass
125
129
  class AuditReport:
126
130
  divergences: list[Divergence] = field(default_factory=list)
131
+ warnings: list[str] = field(default_factory=list)
127
132
 
128
133
  @property
129
134
  def ok(self) -> bool:
135
+ # Warnings are advisory (e.g. an unverifiable external reusable-workflow
136
+ # context, issue #341); only divergences fail the audit.
130
137
  return not self.divergences
131
138
 
132
139
  def add(self, *args: Any, **kwargs: Any) -> None:
133
140
  self.divergences.append(Divergence(*args, **kwargs))
134
141
 
142
+ def warn(self, message: str) -> None:
143
+ self.warnings.append(message)
144
+
135
145
  def render(self) -> str:
146
+ warning_block = "".join(
147
+ f"[branch-protection-audit] WARNING: {w}\n" for w in self.warnings
148
+ )
136
149
  if self.ok:
137
- return "[branch-protection-audit] OK: spec.yaml matches live ruleset.\n"
150
+ return (
151
+ warning_block
152
+ + "[branch-protection-audit] OK: spec.yaml matches live ruleset.\n"
153
+ )
138
154
  body = "\n".join(d.render() for d in self.divergences)
139
155
  return (
140
- "[branch-protection-audit] FAIL: spec.yaml vs live ruleset divergence:\n"
156
+ warning_block
157
+ + "[branch-protection-audit] FAIL: spec.yaml vs live ruleset divergence:\n"
141
158
  f"{body}\n"
142
159
  )
143
160
 
@@ -152,37 +169,126 @@ def load_spec(path: Path) -> dict[str, Any]:
152
169
  return data
153
170
 
154
171
 
155
- def extract_workflow_emitted_contexts(workflows_dir: Path) -> set[str]:
172
+ @dataclass
173
+ class EmittedContexts:
174
+ """Status-check contexts a workflow directory statically emits.
175
+
176
+ ``static`` — contexts resolvable offline: each job's ID, its static
177
+ ``name:``, and — for a job that calls a LOCAL reusable workflow
178
+ (``uses: ./.github/workflows/<wf>.yml``) — the compound context
179
+ ``<caller-job-id> / <callee-job-name>`` for every top-level job of that
180
+ reusable workflow (GitHub emits one status check per inner job, prefixed
181
+ by the CALLER JOB ID; CONSUMER-ADOPTION.md §8).
182
+
183
+ ``unresolvable_external_callers`` — maps a reusable-caller job's ID to its
184
+ ``uses:`` ref, for callers that invoke an EXTERNAL reusable workflow
185
+ (``<owner>/<repo>/.github/workflows/..@<ref>``). Their inner job names live
186
+ in another repo and cannot be resolved offline, so a required compound
187
+ context ``<caller-job-id> / <x>`` is UNVERIFIABLE here — skipped with a
188
+ warning rather than failed (issue #341).
189
+ """
190
+
191
+ static: set[str] = field(default_factory=set)
192
+ unresolvable_external_callers: dict[str, str] = field(default_factory=dict)
193
+
194
+
195
+ def _job_display_label(job_id: str, job: dict[str, Any]) -> str:
196
+ """A job's display name: its static ``name:`` when set (and free of
197
+ unresolved ``${{ }}`` expressions), else the job ID. For a reusable
198
+ workflow's inner job this is the ``<callee-job-name>`` segment of the
199
+ emitted check (it defaults to the job id when no ``name:`` is set). The
200
+ CALLER segment of a reusable context is always the caller JOB ID, not this
201
+ label (CONSUMER-ADOPTION.md §8)."""
202
+ name = job.get("name")
203
+ if isinstance(name, str) and name and "${{" not in name:
204
+ return name
205
+ return job_id
206
+
207
+
208
+ def _read_workflow_jobs(path: Path) -> dict[str, Any] | None:
209
+ """Parse a workflow file and return its ``jobs`` mapping, or ``None`` if the
210
+ file is missing, unreadable, malformed, or declares no jobs mapping."""
211
+ try:
212
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
213
+ except (OSError, yaml.YAMLError):
214
+ return None
215
+ if not isinstance(data, dict):
216
+ return None
217
+ jobs = data.get("jobs")
218
+ return jobs if isinstance(jobs, dict) else None
219
+
220
+
221
+ def extract_workflow_emitted_contexts(workflows_dir: Path) -> EmittedContexts:
156
222
  """Collect status-check contexts that workflow files statically emit.
157
223
 
158
- A workflow job emits a context equal to its ``name:`` value if one is
159
- declared, otherwise its job ID. This is the simple, static case. We do
160
- **not** expand matrix job names because that requires evaluating the
161
- matrix at runtime; callers must supplement with a manual exception for
162
- matrix-generated contexts if needed.
224
+ A normal job emits a context equal to its ``name:`` if declared, else its
225
+ job ID. A job that calls a reusable workflow (a job-level ``uses:``) emits
226
+ one status check per inner job of that reusable workflow, named
227
+ ``<caller-job-id> / <callee-job-name>`` the caller segment is the job id,
228
+ not a caller ``name:`` override (CONSUMER-ADOPTION.md §8):
229
+
230
+ * LOCAL reusable (``uses: ./.github/workflows/<wf>.yml``) — reusable
231
+ workflows must live in ``.github/workflows/``, i.e. the directory being
232
+ scanned, so the referenced file is read by basename and every top-level
233
+ inner job's compound context is added to ``static`` (full offline
234
+ verification; a bogus ``<caller-job-id> / <x>`` still fails).
235
+ * EXTERNAL reusable (``<owner>/<repo>/.github/workflows/..@<ref>``) — the
236
+ inner jobs live in another repo, so the caller job id is recorded in
237
+ ``unresolvable_external_callers`` and its compound contexts are
238
+ skipped-with-warning instead of failed-closed (issue #341).
239
+
240
+ Matrix job names are still **not** expanded (they require runtime
241
+ evaluation); a required matrix-expanded context still surfaces as a
242
+ divergence.
163
243
  """
164
- emitted: set[str] = set()
244
+ result = EmittedContexts()
165
245
  if not workflows_dir.exists():
166
- return emitted
246
+ return result
167
247
  for ext in ("*.yml", "*.yaml"):
168
248
  for path in workflows_dir.glob(ext):
169
- try:
170
- data = yaml.safe_load(path.read_text(encoding="utf-8"))
171
- except (OSError, yaml.YAMLError):
172
- continue
173
- if not isinstance(data, dict):
174
- continue
175
- jobs = data.get("jobs") or {}
176
- if not isinstance(jobs, dict):
249
+ jobs = _read_workflow_jobs(path)
250
+ if jobs is None:
177
251
  continue
178
252
  for job_id, job in jobs.items():
179
253
  if not isinstance(job, dict):
180
254
  continue
181
- emitted.add(job_id)
255
+ result.static.add(job_id)
182
256
  job_name = job.get("name")
183
257
  if isinstance(job_name, str) and job_name:
184
- emitted.add(job_name)
185
- return emitted
258
+ result.static.add(job_name)
259
+
260
+ uses = job.get("uses")
261
+ if not isinstance(uses, str) or not uses.strip():
262
+ continue # normal job — bare job_id/name already recorded.
263
+
264
+ # GitHub prefixes a reusable workflow's emitted checks with the
265
+ # CALLER JOB ID — never the caller's `name:` display override:
266
+ # `<caller-job-id> / <callee-job-name>` (CONSUMER-ADOPTION.md §8;
267
+ # a caller `name:` broke this contract in issue #27).
268
+ if uses.startswith("./"):
269
+ # LOCAL reusable workflow. Resolve by basename within the
270
+ # workflows dir (reusable workflows must live there) and add
271
+ # every inner job's compound context to `static`.
272
+ inner_path = workflows_dir / Path(uses.split("@", 1)[0]).name
273
+ inner_jobs = _read_workflow_jobs(inner_path)
274
+ if inner_jobs is None:
275
+ # Genuinely unresolvable local ref (missing/broken
276
+ # file). Add nothing so a required compound context
277
+ # falls through to the normal fail path, preserving the
278
+ # #168/#175 never-goes-green guard.
279
+ continue
280
+ for inner_id, inner_job in inner_jobs.items():
281
+ if not isinstance(inner_job, dict):
282
+ continue
283
+ callee_name = _job_display_label(inner_id, inner_job)
284
+ result.static.add(f"{job_id} / {callee_name}")
285
+ else:
286
+ # EXTERNAL reusable workflow — inner jobs are in another
287
+ # repo, unresolvable offline. Key on the caller JOB ID (the
288
+ # documented context prefix) so
289
+ # compare_emitted_status_checks can skip-with-warning.
290
+ result.unresolvable_external_callers[job_id] = uses
291
+ return result
186
292
 
187
293
 
188
294
  def compare_emitted_status_checks(
@@ -200,9 +306,28 @@ def compare_emitted_status_checks(
200
306
  return
201
307
  emitted = extract_workflow_emitted_contexts(workflows_dir)
202
308
  for context in required_contexts:
203
- if context in emitted:
309
+ if context in emitted.static:
204
310
  continue
205
- closest = difflib.get_close_matches(context, sorted(emitted), n=1, cutoff=0.5)
311
+ # Reusable-workflow caller (issue #341): a compound context
312
+ # ``<caller> / <inner>`` whose ``<caller>`` invokes an EXTERNAL reusable
313
+ # workflow is unverifiable offline — skip WITH A WARNING, do not fail.
314
+ # LOCAL reusable callers are already fully enumerated in `static`, and a
315
+ # ``<caller>`` that is a normal/nonexistent job is NOT in this set, so
316
+ # both still fall through to the fail path below (non-weakening).
317
+ caller = context.partition(" / ")[0] if " / " in context else None
318
+ if caller and caller in emitted.unresolvable_external_callers:
319
+ uses_ref = emitted.unresolvable_external_callers[caller]
320
+ report.warn(
321
+ f"Required context {context!r} is emitted by reusable-workflow "
322
+ f"caller job {caller!r} (uses: {uses_ref}), whose inner job name "
323
+ f"lives in an external repo and cannot be resolved offline. "
324
+ f"Emission is UNVERIFIED here — skipped, not failed (issue #341). "
325
+ f"Confirm that reusable workflow emits this check at runtime."
326
+ )
327
+ continue
328
+ closest = difflib.get_close_matches(
329
+ context, sorted(emitted.static), n=1, cutoff=0.5
330
+ )
206
331
  closest_name = closest[0] if closest else None
207
332
  report.add(
208
333
  field="required_status_checks.contexts.emitted",
@@ -211,8 +336,9 @@ def compare_emitted_status_checks(
211
336
  detail=(
212
337
  f"Required context {context!r} is not emitted by any job ID or "
213
338
  f"job name in {workflows_dir / '*.yml'} or {workflows_dir / '*.yaml'}. "
214
- f"Matrix-expanded names and reusable-workflow caller contexts are not "
215
- f"statically checked."
339
+ f"Matrix-expanded names are not statically checked; reusable-workflow "
340
+ f"caller contexts are verified against LOCAL reusable workflows and "
341
+ f"skipped-with-warning only for EXTERNAL ones."
216
342
  ),
217
343
  )
218
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momentiq/dark-factory-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
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",
@@ -27,18 +27,30 @@ unassigned; it prints the Issue URL. Link the relevant PR or issue with
27
27
 
28
28
  ```bash
29
29
  df handoff --link pr:<PR#> <<'EOF'
30
- <the composed, scrubbed note see structure below>
30
+ <the composed, scrubbed note from "Dual-register" below
31
+ INCLUDING its <!-- agent-context:v1 --> … <!-- /agent-context:v1 --> markers>
31
32
  EOF
32
33
  ```
33
34
 
35
+ The note **must** be wrapped in the `<!-- agent-context:v1 -->` …
36
+ `<!-- /agent-context:v1 -->` markers (they anchor the Issue-body upsert);
37
+ `df handoff` refuses a note that is missing them.
38
+
34
39
  ## Dual-register: two audiences, one artifact
35
40
 
36
41
  Write the note for the **engineer**, and speak a separate plain sentence to the
37
42
  **operator**.
38
43
 
39
- Engineer-facing note (structured, precise):
44
+ Engineer-facing note (structured, precise). Keep the `agent-context:v1` markers
45
+ around the whole note — copy this verbatim into the heredoc above:
40
46
 
41
47
  ```markdown
48
+ <!-- agent-context:v1 -->
49
+ > 🤖 **Handoff context** — transient working memory, not a source of truth.
50
+ > _Updated: <YYYY-MM-DD> by <your session>_
51
+
52
+ **Work-stream:** `<the ask, in one line>` · the handoff Issue's title.
53
+
42
54
  ## Intent
43
55
  What the operator asked for, in their words.
44
56
 
@@ -48,6 +60,7 @@ What is already built and where (branch or PR), and what is verified visually.
48
60
  ## Blocked — why
49
61
  The specific boundary: which scope, protected path, dependency, or gate — and
50
62
  what an engineer needs to decide or do next.
63
+ <!-- /agent-context:v1 -->
51
64
  ```
52
65
 
53
66
  Operator-facing sentence (plain, framed as progress):
@@ -18,12 +18,14 @@ The brief's **Done-when checkboxes are the acceptance criteria.** Before
18
18
  shipping, confirm each is satisfied and backed by the before/after screenshots
19
19
  from `/designer-build` — a Done-when item with no visible proof is not done.
20
20
 
21
- Make "done" proof-bound, not free text. Where the change has a
22
- `.darkfactory/objectives.yaml`, verify it and read the closeout proof with
23
- `df prove` (per-objective proven/pending/failed); see the `/objectives` skill
24
- for authoring and binding mechanics. (Automated derivation of objectives
25
- directly from a brief is a later Designer-Mode phase; today, treat the Done-when
26
- list as the contract and verify each item visually.)
21
+ Make "done" proof-bound, not free text. Derive an objectives contract straight
22
+ from the brief with **`df objectives derive --brief <brief-path> --apply`** — it
23
+ writes one objective per `## Done-when` checkbox to `.darkfactory/objectives.yaml`,
24
+ each text-hash-bound to its source line (id form `brief-<slug>#ac<k>`). Verify the
25
+ bindings and read the closeout proof with `df prove` (per-objective
26
+ proven/pending/failed); see the `/objectives` skill for authoring and binding
27
+ mechanics. Still confirm each Done-when item **visually** — the before/after
28
+ screenshots are the evidence behind the proof.
27
29
 
28
30
  ## 2. Run the gates locally first
29
31
 
@@ -42,14 +44,15 @@ Mode: designer
42
44
  Closes #<N>
43
45
  ```
44
46
 
45
- The `Mode: designer` trailer tags the PR as designer-driven. Today, scope is
46
- enforced live by the PreToolUse guard during the session, and the change still
47
- faces the full multi-vendor critic quorum + merge queue like any other PR. The
48
- deterministic mode-scope critic which reads this trailer to validate the diff
49
- against the catalog's writeScopes / protectedPaths / diffBudget and block an
50
- out-of-scope diff at merge time, even from a jailbroken local session — lands in
51
- a later Designer-Mode phase; the trailer is the label it will key on.
52
- `Closes #<N>` links the tracking issue.
47
+ The `Mode: designer` trailer is what the **deterministic mode-scope critic**
48
+ keys on: it reads the trailer, loads the catalog, and validates the diff against
49
+ the mode's `writeScopes` / `protectedPaths` / `diffBudget` / `dependencyPolicy`,
50
+ blocking an out-of-scope diff at merge time even from a jailbroken local
51
+ session, because CI re-derives scope from the *committed* trailer + catalog
52
+ independently of whatever happened locally. Scope is *also* enforced live
53
+ in-session by the PreToolUse guard, and the change still faces the full
54
+ multi-vendor critic quorum + merge queue like any other PR. `Closes #<N>` links
55
+ the tracking issue.
53
56
 
54
57
  Open the PR and embed the **before/after screenshots** in the body so the change
55
58
  is visible to reviewers and the operator: