@kontourai/flow-agents 2.4.0 → 3.0.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 (89) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +12 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +23 -0
  5. package/CONTEXT.md +21 -0
  6. package/build/src/cli/assignment-provider.d.ts +1 -0
  7. package/build/src/cli/assignment-provider.js +748 -0
  8. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  9. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  10. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  11. package/build/src/cli/workflow-sidecar.js +157 -110
  12. package/build/src/cli.js +6 -0
  13. package/build/src/tools/validate-source-tree.js +1 -0
  14. package/context/contracts/artifact-contract.md +2 -0
  15. package/context/contracts/assignment-provider-contract.md +239 -0
  16. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  17. package/context/contracts/decision-registry-contract.md +2 -0
  18. package/context/contracts/delivery-contract.md +2 -0
  19. package/context/contracts/execution-contract.md +25 -0
  20. package/context/contracts/governance-adapter-contract.md +2 -0
  21. package/context/contracts/knowledge-store-contract.md +197 -0
  22. package/context/contracts/planning-contract.md +2 -0
  23. package/context/contracts/review-contract.md +2 -0
  24. package/context/contracts/sandbox-policy.md +2 -0
  25. package/context/contracts/standing-directives.md +13 -0
  26. package/context/contracts/verification-contract.md +2 -0
  27. package/context/contracts/work-item-contract.md +2 -0
  28. package/context/settings/assignment-provider-settings.json +33 -0
  29. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +180 -0
  30. package/docs/context-map.md +1 -0
  31. package/docs/decisions/index.md +3 -0
  32. package/docs/decisions/knowledge-store-provider.md +51 -0
  33. package/docs/decisions/model-routing.md +63 -0
  34. package/docs/decisions/standing-directives.md +66 -0
  35. package/docs/fixture-ownership.md +1 -0
  36. package/docs/workflow-shared-contracts.md +2 -1
  37. package/docs/workflow-usage-guide.md +6 -0
  38. package/evals/ci/run-baseline.sh +6 -0
  39. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  40. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  41. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  42. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  43. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  44. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  45. package/evals/integration/test_assignment_provider_github.sh +318 -0
  46. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  47. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  48. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  49. package/evals/integration/test_publish_delivery.sh +21 -4
  50. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  51. package/evals/integration/test_pull_work_liveness_preflight.sh +14 -6
  52. package/evals/integration/test_reconcile_soundness.sh +33 -9
  53. package/evals/integration/test_trust_reconcile.sh +9 -8
  54. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  55. package/evals/integration/test_workflow_sidecar_writer.sh +79 -0
  56. package/evals/run.sh +10 -0
  57. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  58. package/evals/static/test_knowledge_providers.sh +23 -0
  59. package/kits/builder/skills/deliver/SKILL.md +19 -0
  60. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  61. package/kits/knowledge/kit.json +35 -0
  62. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  63. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  64. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  65. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  66. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  67. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  68. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  69. package/kits/knowledge/providers/git-repo/index.js +236 -0
  70. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  71. package/kits/knowledge/providers/health/index.js +153 -0
  72. package/kits/knowledge/providers/index.js +24 -0
  73. package/kits/knowledge/providers/lib/model.js +91 -0
  74. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  75. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  76. package/kits/knowledge/providers/work-item/index.js +204 -0
  77. package/package.json +4 -2
  78. package/schemas/assignment-provider-settings.schema.json +125 -0
  79. package/schemas/knowledge/edge.schema.json +54 -0
  80. package/schemas/knowledge/health-report.schema.json +45 -0
  81. package/schemas/knowledge/node.schema.json +49 -0
  82. package/schemas/knowledge/proposal.schema.json +53 -0
  83. package/scripts/ci/trust-reconcile.js +521 -24
  84. package/src/cli/assignment-provider.ts +845 -0
  85. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  86. package/src/cli/validate-workflow-artifacts.ts +5 -1
  87. package/src/cli/workflow-sidecar.ts +147 -106
  88. package/src/cli.ts +6 -0
  89. package/src/tools/validate-source-tree.ts +1 -0
@@ -2791,6 +2791,85 @@ else
2791
2791
  _fail "trust.bundle capture-authoritative setup failed: $(cat "$TMPDIR_EVAL/tb-capture-evidence.out" "$TMPDIR_EVAL/tb-capture-evidence.err")"
2792
2792
  fi
2793
2793
 
2794
+ # ---- #347: hard cutover -- an unstamped (pre-#344) claim in a session trust bundle is a loud,
2795
+ # typed error; checksFromBundle/critiquesFromBundle carry NO claimType-derivation fallback (that
2796
+ # fallback WAS the #268 defect kept reachable). ----------------------------------------------
2797
+ TB_UNSTAMPED_DIR="$TMPDIR_EVAL/repo/.kontourai/flow-agents/trust-bundle-unstamped"
2798
+ mkdir -p "$TB_UNSTAMPED_DIR"
2799
+ cp "$ARTIFACT_DIR/auto-sidecars--deliver.md" "$TB_UNSTAMPED_DIR/trust-bundle-unstamped--deliver.md"
2800
+ flow_agents_node "$WRITER" init-plan "$TB_UNSTAMPED_DIR/trust-bundle-unstamped--deliver.md" \
2801
+ --source-request "Unstamped bundle regression fixture." \
2802
+ --summary "Unstamped bundle regression fixture." \
2803
+ --next-action "Seed a stamped check, then strip its origin stamp to simulate a pre-#344 bundle." \
2804
+ --timestamp "2026-05-09T00:00:00Z" >"$TMPDIR_EVAL/tb-unstamped-init.out" 2>"$TMPDIR_EVAL/tb-unstamped-init.err"
2805
+ flow_agents_node "$WRITER" record-evidence "$TB_UNSTAMPED_DIR" \
2806
+ --verdict pass \
2807
+ --check-json '{"id":"tb-unstamped-check","kind":"test","status":"pass","summary":"Stamped check, about to be corrupted."}' \
2808
+ --timestamp "2026-05-09T00:01:00Z" >"$TMPDIR_EVAL/tb-unstamped-evidence.out" 2>"$TMPDIR_EVAL/tb-unstamped-evidence.err"
2809
+
2810
+ # Simulate a pre-#344 bundle by stripping the origin/check_kind stamp from every claim -- never
2811
+ # done by hand-editing production sessions, only here to recreate the on-disk shape of a bundle
2812
+ # recorded before #344 shipped stamping.
2813
+ export UNSTAMPED_TARGET="$TB_UNSTAMPED_DIR/trust.bundle"
2814
+ node --input-type=module <<NODEOF
2815
+ import { readFileSync, writeFileSync } from 'node:fs';
2816
+ const target = process.env.UNSTAMPED_TARGET;
2817
+ const data = JSON.parse(readFileSync(target, 'utf8'));
2818
+ data.claims = (data.claims || []).map((c) => {
2819
+ if (c && c.metadata && typeof c.metadata === 'object') {
2820
+ const rest = { ...c.metadata };
2821
+ delete rest.origin;
2822
+ delete rest.check_kind;
2823
+ return { ...c, metadata: rest };
2824
+ }
2825
+ return c;
2826
+ });
2827
+ writeFileSync(target, JSON.stringify(data, null, 2) + '\n');
2828
+ NODEOF
2829
+
2830
+ if flow_agents_node "$WRITER" record-critique "$TB_UNSTAMPED_DIR" \
2831
+ --id tb-unstamped-review \
2832
+ --reviewer tool-code-reviewer \
2833
+ --verdict pass \
2834
+ --summary "Should never be recorded -- the bundle is unstamped." \
2835
+ --timestamp "2026-05-09T00:02:00Z" >"$TMPDIR_EVAL/tb-unstamped-critique.out" 2>"$TMPDIR_EVAL/tb-unstamped-critique.err"; then
2836
+ _fail "record-critique should refuse to read an unstamped (pre-#344) trust.bundle, not silently reclassify it by claimType"
2837
+ elif rg -q 'pre-supersession trust\.bundle' "$TMPDIR_EVAL/tb-unstamped-critique.out" "$TMPDIR_EVAL/tb-unstamped-critique.err" \
2838
+ && rg -qF "$TB_UNSTAMPED_DIR" "$TMPDIR_EVAL/tb-unstamped-critique.out" "$TMPDIR_EVAL/tb-unstamped-critique.err" \
2839
+ && rg -q 're-record evidence to regenerate' "$TMPDIR_EVAL/tb-unstamped-critique.out" "$TMPDIR_EVAL/tb-unstamped-critique.err" \
2840
+ && rg -q 'record-evidence' "$TMPDIR_EVAL/tb-unstamped-critique.out" "$TMPDIR_EVAL/tb-unstamped-critique.err"; then
2841
+ _pass "record-critique refuses an unstamped (pre-#344) trust.bundle: typed error names the session dir and the record-evidence remedy (no claimType-derivation fallback, #347)"
2842
+ else
2843
+ _fail "record-critique on an unstamped bundle failed for the wrong reason: $(cat "$TMPDIR_EVAL/tb-unstamped-critique.out" "$TMPDIR_EVAL/tb-unstamped-critique.err")"
2844
+ fi
2845
+
2846
+ # The same unstamped bundle also refuses promote (checksFromBundle) -- not just
2847
+ # record-critique/critiquesFromBundle.
2848
+ if flow_agents_node "$WRITER" promote "$TB_UNSTAMPED_DIR" --none --reason "regression probe" \
2849
+ --timestamp "2026-05-09T00:03:00Z" >"$TMPDIR_EVAL/tb-unstamped-promote.out" 2>"$TMPDIR_EVAL/tb-unstamped-promote.err"; then
2850
+ _fail "promote should refuse to read an unstamped (pre-#344) trust.bundle via checksFromBundle"
2851
+ elif rg -q 'pre-supersession trust\.bundle' "$TMPDIR_EVAL/tb-unstamped-promote.out" "$TMPDIR_EVAL/tb-unstamped-promote.err"; then
2852
+ _pass "promote (checksFromBundle) also refuses an unstamped (pre-#344) trust.bundle with the same typed error"
2853
+ else
2854
+ _fail "promote on an unstamped bundle failed for the wrong reason: $(cat "$TMPDIR_EVAL/tb-unstamped-promote.out" "$TMPDIR_EVAL/tb-unstamped-promote.err")"
2855
+ fi
2856
+
2857
+ # (b) STAMPED bundles are unaffected: the earlier, still-stamped trust-bundle-critique fixture
2858
+ # carries the origin stamp on every claim -- the normal write path was never touched by this cutover.
2859
+ export STAMPED_TARGET="$TB_CRITIQUE_DIR/trust.bundle"
2860
+ if [[ -f "$STAMPED_TARGET" ]] && node --input-type=module <<NODEOF 2>"$TMPDIR_EVAL/tb-stamped-unaffected.err"
2861
+ import { readFileSync } from 'node:fs';
2862
+ const target = process.env.STAMPED_TARGET;
2863
+ const bundle = JSON.parse(readFileSync(target, 'utf8'));
2864
+ const unstamped = bundle.claims.filter((c) => !(c.metadata && typeof c.metadata === 'object' && typeof c.metadata.origin === 'string' && c.metadata.origin.length > 0));
2865
+ if (unstamped.length > 0) { process.stderr.write('found ' + unstamped.length + ' claim(s) with no origin stamp in a normally-written bundle\n'); process.exit(1); }
2866
+ NODEOF
2867
+ then
2868
+ _pass "stamped bundles are unaffected: every claim in a normally-written trust bundle carries the origin stamp (#347)"
2869
+ else
2870
+ _fail "stamped-bundle regression: $(cat "$TMPDIR_EVAL/tb-stamped-unaffected.err")"
2871
+ fi
2872
+
2794
2873
  # ─── AC4: render-trust-panel projects the bundle to a standalone Surface Trust Panel (ADR 0010 Phase 3) ──
2795
2874
  if [[ -f "$TB_CAPTURE_DIR/trust.bundle" ]] && flow_agents_node "$WRITER" render-trust-panel "$TB_CAPTURE_DIR" --out "$TB_CAPTURE_DIR/trust-panel.html" >"$TMPDIR_EVAL/tb-panel.out" 2>"$TMPDIR_EVAL/tb-panel.err"; then
2796
2875
  PANEL="$TB_CAPTURE_DIR/trust-panel.html"
package/evals/run.sh CHANGED
@@ -146,6 +146,8 @@ run_static() {
146
146
  bash "$EVAL_DIR/static/test_flowdef_codeowners_coverage.sh" || result=1
147
147
  echo ""
148
148
  bash "$EVAL_DIR/static/test_unit_helpers.sh" || result=1
149
+ echo ""
150
+ bash "$EVAL_DIR/static/test_knowledge_providers.sh" || result=1
149
151
  return $result
150
152
  }
151
153
 
@@ -195,6 +197,12 @@ run_integration() {
195
197
  echo ""
196
198
  bash "$EVAL_DIR/integration/test_actor_identity.sh" || result=1
197
199
  echo ""
200
+ bash "$EVAL_DIR/integration/test_assignment_provider_local_file.sh" || result=1
201
+ echo ""
202
+ bash "$EVAL_DIR/integration/test_assignment_provider_github.sh" || result=1
203
+ echo ""
204
+ bash "$EVAL_DIR/integration/test_pull_work_assignment_join.sh" || result=1
205
+ echo ""
198
206
  bash "$EVAL_DIR/integration/test_liveness_heartbeat.sh" || result=1
199
207
  echo ""
200
208
  bash "$EVAL_DIR/integration/test_pull_work_liveness_preflight.sh" || result=1
@@ -241,6 +249,8 @@ run_integration() {
241
249
  echo ""
242
250
  bash "$EVAL_DIR/integration/test_flowdef_session_history_preservation.sh" || result=1
243
251
  echo ""
252
+ bash "$EVAL_DIR/integration/test_critique_supersession_roundtrip.sh" || result=1
253
+ echo ""
244
254
  bash "$EVAL_DIR/integration/test_flowdef_session_activation.sh" || result=1
245
255
  echo ""
246
256
  bash "$EVAL_DIR/integration/test_trust_checkpoint.sh" || result=1
@@ -53,4 +53,10 @@ require_codeowner "src/li""b/flow-resolver.ts" "src/li""b/flow-resolver.ts"
53
53
  require_codeowner "src/cli/workflow-sidecar.ts" "src/cli/workflow-sidecar.ts"
54
54
  require_codeowner "kits/*/flows/*.flow.json" "kits/*/flows/*.flow.json (kit FlowDefinitions)"
55
55
 
56
+ # ADR 0022 §2: delivery/DECLARED is the no-agent-delivery exemption marker
57
+ # scripts/ci/trust-reconcile.js reads — a self-asserted governance artifact (names
58
+ # approved_by) that must stay owner-review-gated the same as the other verify-config/
59
+ # anchor paths above (#301's file-level half; server-side review enforcement is #225).
60
+ require_codeowner "delivery/DECLARED" "delivery/DECLARED (ADR 0022 §2 no-agent-delivery marker)"
61
+
56
62
  echo "FlowDefinition-related CODEOWNERS coverage checks passed."
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ # test_knowledge_providers.sh — Layer 1: Knowledge Store Provider contract
3
+ # conformance + provider-agnostic health verbs (issue #317).
4
+ #
5
+ # Runs the node:test suites that back AC1–AC3:
6
+ # - providers/conformance/suite.test.js (AC3: all three providers conform)
7
+ # - providers/health/health-pass.test.js (AC1: vault+git-repo reports;
8
+ # AC2: work-item seeded dupe + broken blocker)
9
+ # Deterministic, dependency-free, fixture-driven — never touches a real board.
10
+ set -uo pipefail
11
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
+ cd "$ROOT_DIR"
13
+
14
+ echo "── Knowledge store provider conformance + health (node --test) ──"
15
+
16
+ if node --test \
17
+ kits/knowledge/providers/conformance/suite.test.js \
18
+ kits/knowledge/providers/health/health-pass.test.js; then
19
+ echo " PASS: knowledge store provider conformance + health verbs"
20
+ else
21
+ echo " FAIL: knowledge store provider conformance + health verbs"
22
+ exit 1
23
+ fi
@@ -20,6 +20,24 @@ Inherited from primitives:
20
20
  | tool-verifier | verify-work |
21
21
  | tool-playwright | verify-work |
22
22
 
23
+ ## Model Routing
24
+
25
+ Delegates are spawned with an explicit model override resolved from
26
+ `.datum/config.json` via `npx @kontourai/datum resolve <role> --json`
27
+ (see `context/contracts/execution-contract.md` § Delegation: Model Routing —
28
+ that contract is the consumption instruction, `.datum/config.json` is the
29
+ source of truth for the mapping):
30
+
31
+ | Delegate | Role |
32
+ |---|---|
33
+ | tool-worker | `delegate-mechanical` for fully-specified mechanical tasks, `delegate-implementation` for precisely-planned implementation, `delegate-design` when the task needs design latitude |
34
+ | tool-planner | `delegate-design` |
35
+ | tool-code-reviewer / tool-security-reviewer | `delegate-implementation` |
36
+ | tool-verifier / tool-playwright | `delegate-implementation` |
37
+
38
+ If datum or the config is absent, fall back to the runtime's inherited model
39
+ and note the fallback in the session artifact.
40
+
23
41
  ## Orchestrator Rule
24
42
 
25
43
  You never use `read`, `glob`, `grep`, or `code` on source files. You only read/write the session file and artifact files in `.kontourai/flow-agents/<slug>/`.
@@ -27,6 +45,7 @@ You never use `read`, `glob`, `grep`, or `code` on source files. You only read/w
27
45
  ## Shared Contracts
28
46
 
29
47
  Follow:
48
+ - `context/contracts/standing-directives.md`
30
49
  - `context/contracts/artifact-contract.md`
31
50
  - `context/contracts/planning-contract.md`
32
51
  - `context/contracts/execution-contract.md`
@@ -10,6 +10,8 @@ Select ready backlog work and prepare a bounded execution handoff without implem
10
10
  ## Contract
11
11
 
12
12
  - Use `context/contracts/work-item-contract.md` as the source vocabulary for provider-backed work item shape, provider roles, and capability flags.
13
+ - Use `context/contracts/assignment-provider-contract.md` as the second source vocabulary for durable ownership: the `AssignmentProvider` operations (`claim`/`release`/`supersede`/`status`/`list`), the assignment ⋈ liveness join's effective-state enum (`held`/`reclaimable`/`human-held`/`free`), and the versioned claim-record format come from that contract, not from this skill.
14
+ - `pull-work` may perform exactly two provider-adjacent writes at selection: the liveness claim (ADR 0012, ephemeral, local runtime presence stream — see "1a. Liveness Selection Preflight" / "Liveness Claim On Selection") and the durable assignment claim (`context/contracts/assignment-provider-contract.md`, GitHub assignee/label/comment or a local-file record — see "Assignment Claim On Selection"); no other provider mutation is added to this skill.
13
15
  - Read the configured backlog provider dynamically.
14
16
  - Select one issue or a coherent issue group.
15
17
  - Do not implement code.
@@ -37,9 +39,10 @@ Create or update `.kontourai/flow-agents/<slug>/<slug>--pull-work.md` with:
37
39
 
38
40
  - `board_snapshot`: filters, issue list, labels, milestone/provider milestone state, project fields, state, blockers
39
41
  - `wip_assessment`: active work, reviews, verification, CI remediation, with personal WIP separated from global conflict context
40
- - `liveness_preflight`: per-candidate `{subjectId, state: held|reclaimable|free|mine, holder_actor?, status_raw}` plus `self_actor`/`self_actor_source`, computed via `liveness status --json` + `liveness whoami --json`; `self_actor` is captured exactly once per pull-work pass and pinned for reuse (see "1a. Liveness Selection Preflight" and "Liveness Claim On Selection" — never re-derived per claim call); this is the liveness-only projection of ADR 0021 §1's join, pending #290's assignment dimension
42
+ - `liveness_preflight`: per-candidate `{subjectId, state: held|reclaimable|human-held|free|mine, holder_actor?, status_raw, effective_reason?}` plus `self_actor`/`self_actor_source`, computed via `liveness status --json` + `liveness whoami --json` joined with `assignment-provider status`'s `effective_state`/`reason` (see "1a. Liveness Selection Preflight"); `self_actor` is captured exactly once per pull-work pass and pinned for reuse (see "1a. Liveness Selection Preflight" and "Liveness Claim On Selection" — never re-derived per claim call); this is now the **full** ADR 0021 §1 `assignment ⋈ liveness` join (previously liveness-only pending #290 #290 has landed)
41
43
  - `reclaimable_override`: recorded only when a `reclaimable` (stale) candidate is selected, or when `--force`/an explicit user instruction overrides a `held` exclusion — the explicit opt-in decision and its stated reason (see "1a. Liveness Selection Preflight")
42
44
  - `liveness_claim`: per selected item, `{subjectId, actor, emitted_at, ttl_seconds}`; on any claim-emit failure, `{skipped: <stderr reason>}` instead — fail-open (never block selection on a liveness-emit failure) but never silent: the skip reason is also surfaced in pull-work's user-facing output, and an unresolved-actor failure additionally names the remediation (`--actor <id>` / `FLOW_AGENTS_ACTOR=<id>` / a supported runtime), matching ADR 0012; also carries an optional `post_claim_conflict: {other_actor, detected_at}` when the post-claim re-check (see "Post-Claim Conflict Re-check") detects a double-hold
45
+ - `assignment_claim`: per selected item, `{provider: "github"|"local-file", subject_id, actor, command_evidence, confirmed_status}` — the durable-claim analog of `selected_item_ids`: provider kind, subject id, actor, the rendered/executed command evidence (`gh_commands`/`claim_comment_body` for `github`, the local-file `claim` invocation for `local-file`), and the confirmed `assignment-provider status` result for each selected item, recorded once the post-claim confirmation in "Assignment Claim On Selection" succeeds; see `context/contracts/assignment-provider-contract.md` for the underlying claim-record shape
43
46
  - `my_active_work`: local worktrees, dirty branches, open PRs by the current user, active sidecars, and in-flight review/verification/release work owned by the current user
44
47
  - `shepherding_candidates`: personal PRs, worktrees, or sidecars that should be reviewed, fixed, published, merged, abandoned, or cleaned before starting more work
45
48
  - `stale_worktrees`: worktrees with no open PR, no recent activity, merged/abandoned branches, or unclear ownership that need an explicit keep/remove decision
@@ -136,18 +139,33 @@ For each ready candidate, derive `subjectId` via:
136
139
  npm run workflow:sidecar -- resolve-slug <owner>/<repo>#<issue-number>
137
140
  ```
138
141
 
139
- then group all rows for that `subjectId` by subject (classify per subject, not per row) and classify in this precedence order, reading each row's raw `status` field (never `label`):
142
+ then group all rows for that `subjectId` by subject (classify per subject, not per row), reading each row's raw `status` field (never `label`). Detect the liveness-only double-hold case first, before computing the full assignment ⋈ liveness join below: when a `verified` row for an actor other than self and a `verified` row for the resolved self actor both exist on the same subject, that is a double-hold — surface it as `held` plus an explicit conflict warning (per ADR 0012 §4 detection: "a false-stale double-hold ... is detected ... not prevented"), never silently resolve it to `mine`.
140
143
 
141
- 1. a `verified` row for an actor other than self ⇒ `held`, excluded from the ready set by default, even when a `verified` row for the resolved self actor also exists on the same subject that combination is a double-hold: surface it as `held` plus an explicit conflict warning (per ADR 0012 §4 detection: "a false-stale double-hold ... is detected ... not prevented"), never silently resolve it to `mine`.
142
- 2. else, a `verified` row for the resolved self actor ⇒ `mine`: hand to `### 2. Enforce WIP And Shepherding`'s existing personal-WIP logic; do not re-offer as new, do not exclude as held-by-other.
143
- 3. else, a `stale` row (any actor) ⇒ `reclaimable`: offered, flagged, with a warning; selecting it requires an explicit recorded opt-in (record in `reclaimable_override`, and/or `alignment_questions`), never a silent normal pick.
144
- 4. else (a `revoked`-only row, a `superseded` row, or no row at all) ⇒ `free`, offered normally — except a `superseded` row is surfaced but never auto-selected (full takeover semantics are #294; this is an accepted gap, not silently ignored).
144
+ Otherwise, compute the full ADR 0021 §1 `assignment liveness` join per candidate via `assignment-provider status` (using the effective provider kind, repo, artifact root, `label_name`, and `claim_comment_marker` from `effective-assignment-provider-settings` see "Assignment Claim On Selection" below joined against the same liveness stream already read above):
145
145
 
146
- An explicit user instruction to proceed despite a `held` or `reclaimable` classification (`--force`, "take it anyway", equivalent) overrides the exclusion/opt-in requirement; the override and its stated reason must be recorded in the artifact (`liveness_preflight`, `reclaimable_override`, and/or `priority_rationale`).
146
+ ```bash
147
+ npm run assignment-provider -- status \
148
+ --provider <github|local-file> \
149
+ --subject-id <subjectId> \
150
+ --liveness-stream <path-to-events.jsonl> \
151
+ --self-actor <self_actor>
152
+ # github: --issue-json <path-to-gh-issue-view-json|-> --label-name <label> --claim-comment-marker <marker>
153
+ # local-file: --artifact-root <dir>
154
+ ```
155
+
156
+ Read the returned `.effective.effective_state` and `.effective.reason` and classify in this precedence order:
157
+
158
+ 1. `effective_state: "held"` with `reason: "self_is_holder"` ⇒ `mine`: hand to `### 2. Enforce WIP And Shepherding`'s existing personal-WIP logic; do not re-offer as new, do not exclude as held-by-other.
159
+ 2. else `effective_state: "held"` (`reason: "fresh_liveness_heartbeat"` or `"liveness_claim_present_assignment_lagging"`) ⇒ excluded from the ready set by default.
160
+ 3. else `effective_state: "reclaimable"` ⇒ offered, flagged, with a warning; selecting it requires an explicit recorded opt-in (record in `reclaimable_override`, and/or `alignment_questions`), never a silent normal pick. This issue does not implement takeover — offering a `reclaimable` candidate, never auto-reclaiming it, is the correct scope boundary (Design Decision 2; full takeover protocol is ADR 0021 §5 / #294).
161
+ 4. else `effective_state: "human-held"` ⇒ surfaced, never auto-reclaimed (Design Decision 3 / ADR 0021 §6): record the assignee identity and idle duration (`effective.holder.assignee`, `effective.holder.idle_days`) in `alignment_questions` with a recommended answer (e.g. "assigned to `<assignee>`, idle `<idle_days>` days — reclaim?" recommending confirmation before proceeding), and select only on the user's explicit confirmation.
162
+ 5. else `effective_state: "free"` ⇒ offered normally — except a `superseded` liveness row (no active assignment, no fresh liveness) is surfaced but never auto-selected (full takeover semantics are #294; this is an accepted gap, not silently ignored), exactly as before this join upgrade.
147
163
 
148
- This preflight computes the **liveness-only** projection of ADR 0021 §1's `assignment liveness` join. The assignment dimension is #290's `AssignmentProvider` (durable GitHub-side claim); until it lands, assignment is treated as always-unassigned, which ADR 0021 §1's own table shows is safe because stalenessnot assignment is what excludes.
164
+ An explicit user instruction to proceed despite a `held` or `reclaimable` classification (`--force`, "take it anyway", equivalent) overrides the exclusion/opt-in requirement; the override and its stated reason must be recorded in the artifact (`liveness_preflight`, `reclaimable_override`, and/or `priority_rationale`). A `human-held` classification is never overridden by `--force` alone only the user's explicit answer to the recorded `alignment_questions` entry authorizes selecting it.
149
165
 
150
- `liveness claim`/`status`/`whoami` read/write the local runtime liveness stream, never GitHub issue/label/assignee state this is not a provider mutation, and the existing no-provider-mutation rule (see below) is unchanged by this slice. #290 will add the one narrow, audited GitHub-side mutation that pairs with this liveness emit.
166
+ This preflight now computes the **full** ADR 0021 §1 `assignment liveness` join (previously liveness-only, pending #290): the assignment dimension is `#290`'s `AssignmentProvider` `status()` (`context/contracts/assignment-provider-contract.md`), joined against the same liveness stream this preflight already reads.
167
+
168
+ `liveness claim`/`status`/`whoami` read/write the local runtime liveness stream, never GitHub issue/label/assignee state — this is not a provider mutation, and the two-provider-writes-only invariant in `## Contract` is unchanged by this slice. `#290` adds the one narrow, audited durable assignment claim that pairs with this liveness emit — see "Assignment Claim On Selection" below.
151
169
 
152
170
  ### 2. Enforce WIP And Shepherding
153
171
 
@@ -260,7 +278,57 @@ Branch on the returned `winner.actor`:
260
278
  - If `winner.actor !== self_actor`, this session is the loser: immediately run `npm run workflow:sidecar -- liveness release <subjectId> --actor <self_actor>`, extend `post_claim_conflict` with `{verdict_reason, winner_actor, conceded: true}`, and return to `### 3. Select Work` to reselect within the same `pull-work` pass — excluding the just-released subject — before any handoff to `plan-work`.
261
279
  - If `winner.actor === self_actor`, this session wins: record the verdict for transparency (`{verdict_reason, winner_actor: self_actor, conceded: false}`) in `post_claim_conflict` and proceed normally; do not release.
262
280
 
263
- Honesty note: this re-check narrows, but does not close, the read-then-write race between the preflight's read and the claim's write. The verdict-and-release loop above closes the "detected but advisory-only" gap ADR 0012 §4 names for THIS session's own double-hold — the loser deterministically concedes and re-selects within the same pass, a new convergence guarantee this slice adds — but it still does not provide true mutual exclusion across the read-then-write race window itself; that residual is unchanged from before. True mutual exclusion arrives with the provider assignment lease (#290) and the `verify-hold` publish gate (#293).
281
+ Honesty note: this re-check narrows, but does not close, the read-then-write race between the preflight's read and the claim's write. The verdict-and-release loop above closes the "detected but advisory-only" gap ADR 0012 §4 names for THIS session's own double-hold — the loser deterministically concedes and re-selects within the same pass, a new convergence guarantee this slice adds — but it still does not provide true mutual exclusion across the read-then-write race window itself; that residual is unchanged from before. `#290`'s provider assignment lease closes this gap for the **local-file** provider only: `claim`/`release`/`supersede` there are wrapped in a per-subject `mkdir`-lock (atomic create, EEXIST-spin, staleness reclaim), so two concurrent local-file `claim` calls on the same subject genuinely cannot both win — true mutual exclusion, not just detection. The **GitHub** provider (assignee/label/claim-comment) remains advisory/last-writer: `render-claim`/`render-supersede` emit `gh` argv for the calling skill to run, and nothing about a GitHub issue's assignee/label/comment state gives atomic compare-and-swap across two concurrent skill invocations — the post-claim `status` re-check above still only *detects* a lost race after the fact, it does not *prevent* one. Do not read "provider assignment lease" as closing the GitHub race; only the `verify-hold` publish gate (#293), not yet implemented, would do that for GitHub.
282
+
283
+ ### Assignment Claim On Selection
284
+
285
+ After the post-claim liveness conflict re-check above resolves (no double-hold detected, or this session won the deterministic tiebreaker and any loser has reselected) and `selected_item_ids` is otherwise ready to finalize, perform the durable assignment claim — the second and last provider-adjacent write this skill performs (see `## Contract`) — before recording `selected_item_ids` as final and before any handoff to `plan-work`.
286
+
287
+ Resolve the effective assignment provider settings once per pass:
288
+
289
+ ```bash
290
+ npm run effective-assignment-provider-settings -- --repo-path . --json
291
+ ```
292
+
293
+ If the result is `status: ask_user`, ask the user which `AssignmentProvider` (`github` or `local-file`) to use before claiming; do not silently assume. If `status: configured`, use the returned `settings.provider.kind`, `settings.provider.repo`, `settings.policy.label_name`, and `settings.policy.claim_comment_marker` for every call below.
294
+
295
+ For each selected item's `subjectId` (the same `subjectId` resolved via `resolve-slug` and used throughout this pass):
296
+
297
+ - **`kind: "github"`**: build an `--input-json` payload with `repo` (`{owner, name}`), `issue_number`, `assignee_login` (the current actor's GitHub login when known), `existing_assignee_login` and `existing_comment_id` (only when superseding an already-recorded `reclaimable`/confirmed `human-held` candidate — otherwise omit), `label_name`, `claim_comment_marker`, `ttl_seconds`, `branch`, and `artifact_dir`, then render:
298
+
299
+ ```bash
300
+ npm run assignment-provider -- render-claim \
301
+ --provider github \
302
+ --subject-id <subjectId> \
303
+ --input-json <path> \
304
+ --actor-json <path>
305
+ ```
306
+
307
+ Execute every command in the returned `gh_commands` array **verbatim** via the Bash tool, in order — never freehand `gh` text. Each `gh_commands` entry is an **argv array** (one element per argument, e.g. `["gh", "issue", "comment", "9101", "--repo", "kontourai/flow-agents", "--body", "..."]`) and MUST be executed as argv — every element passed as its own separate Bash-tool argument — **never** concatenated into a single shell-command string, and **never** run via `bash -c "..."` or any other shell re-interpretation of the joined elements. `claim_comment_body` (and, when superseding, `previous_record`) can carry attacker-influenced text (see the GitHub claim-record sanitization note in `context/contracts/assignment-provider-contract.md`); concatenating argv elements into a shell string before execution would reintroduce a shell-injection surface this render/execute split is designed to avoid. Record the rendered `record`, `claim_comment_body`, and each executed `gh_commands` entry as evidence.
308
+
309
+ - **`kind: "local-file"`**: call the local-file `claim` subcommand directly — no render step, since this is the one path that performs real I/O inside the CLI itself:
310
+
311
+ ```bash
312
+ npm run assignment-provider -- claim \
313
+ --provider local-file \
314
+ --artifact-root <artifact-root> \
315
+ --subject-id <subjectId> \
316
+ --branch <branch> \
317
+ --artifact-dir <artifact_dir> \
318
+ --actor-json <path>
319
+ ```
320
+
321
+ A non-zero exit (already claimed by a different actor) must not be silently retried or overwritten — treat it exactly like a `held`/`reclaimable` conflict and return to `### 3. Select Work` to reselect.
322
+
323
+ Then, regardless of provider kind, re-fetch current state — re-fetch the GitHub issue via `gh issue view --json assignees,labels,comments` for `github` (the local-file record is already current on disk) — and call `assignment-provider status` again, passing the same pinned `self_actor`, to **confirm** the claim landed before treating `selected_item_ids` as final:
324
+
325
+ ```bash
326
+ npm run assignment-provider -- status --provider <github|local-file> --subject-id <subjectId> --self-actor <self_actor> ...
327
+ ```
328
+
329
+ Confirm `effective.effective_state === "held"` with `effective.reason === "self_is_holder"` (the join function's signal that this session's own actor is the confirmed holder). Only after this confirmation does `selected_item_ids` become final for handoff. If confirmation fails — the claim did not land, or a different actor's record now appears — treat this exactly like a liveness double-hold: do not proceed to `plan-work` with this subject; return to `### 3. Select Work` to reselect, excluding the contested subject.
330
+
331
+ Record the render/status evidence — provider kind, subject id, actor, the rendered/executed command(s), and the confirmed `status()` result — for every selected item under `selection` and in the dedicated `assignment_claim` artifact field (see Artifact Contract above).
264
332
 
265
333
  ### 4. Anchor Check
266
334
 
@@ -91,6 +91,31 @@
91
91
  "id": "knowledge.obsidian-store",
92
92
  "path": "adapters/obsidian-store/index.js",
93
93
  "description": "Obsidian store adapter: each record is one human-canonical Obsidian markdown note. Frontmatter carries all contract fields; body rendered with callouts (raw), readable prose + Sources/Related wikilink sections (compiled/concept/snapshot). Category dots map to folder hierarchy; title-slugified filenames with collision suffix; superseded records move to archive/ (supersede-not-delete invariant). Backed by shared codec with default-store."
94
+ },
95
+ {
96
+ "id": "knowledge.markdown-vault-provider",
97
+ "path": "providers/markdown-vault/index.js",
98
+ "description": "Knowledge store PROVIDER (issue #317): adapts the markdown-vault store behind the typed graph model (nodes/edges/provenance). Thin read + proposals-only wrapper over an existing store adapter; existing skills unaffected."
99
+ },
100
+ {
101
+ "id": "knowledge.git-repo-provider",
102
+ "path": "providers/git-repo/index.js",
103
+ "description": "Knowledge store PROVIDER (issue #317): reads git-tracked docs (docs/decisions/* tombstones+evidence, CONTEXT.md glossary, docs/learnings/*) as decision/note nodes with supersedes/merged-into/evidence-of edges. Write side = decision-topic proposals shaped for the promote sub-flow."
104
+ },
105
+ {
106
+ "id": "knowledge.work-item-provider",
107
+ "path": "providers/work-item/index.js",
108
+ "description": "Knowledge store PROVIDER/source-sink adapter (issue #317): maps GitHub issues to issue nodes and flow-agents:work-item-metadata blockers + prose refs to blocks/relates edges via an injectable gh runner. Write side = draft comment/label proposals. Enables backlog hygiene as a knowledge-health pass."
109
+ },
110
+ {
111
+ "id": "knowledge.health-verbs",
112
+ "path": "providers/health/index.js",
113
+ "description": "Provider-agnostic knowledge-health verbs (issue #317): detectDuplicates + checkDependencyLinkIntegrity over any provider's graph, returning schema-valid health reports (schemas/knowledge/health-report.schema.json)."
114
+ },
115
+ {
116
+ "id": "knowledge.store-providers",
117
+ "path": "providers/index.js",
118
+ "description": "Public entry point for the knowledge store providers, health verbs, graph model helpers, and schema validator. See context/contracts/knowledge-store-contract.md."
94
119
  }
95
120
  ],
96
121
  "evals": [
@@ -153,6 +178,16 @@
153
178
  "id": "knowledge.hygiene-review-suite",
154
179
  "path": "evals/hygiene-review/suite.test.js",
155
180
  "description": "Eval cases for hygiene-review (#106 hygiene #5, closes the issue): opt-in orchestration (omitted audit block is skipped; empty review does nothing); each opted-in audit runs via its existing flow-runner method and its findings are collected verbatim (no detection reimplemented); proposals are normalized to adopt/retire/merge and each cites its sourceFlow + evidence; thin-orchestrator/consume-never-fork invariants — read-only by default mutates no record, no new gate is forked, the only mutation (glossary apply:true) is delegated to glossarySync's own gated propose→apply (verified via proposes-link + mutation-log on the resulting concept); sub-flow telemetry is folded in plus hygiene-review's own orchestrate-gate + review-gate; module-level export delegates to the runner."
181
+ },
182
+ {
183
+ "id": "knowledge.provider-conformance-suite",
184
+ "path": "providers/conformance/suite.test.js",
185
+ "description": "Conformance suite (issue #317, AC3): parameterized suite every knowledge store provider (markdown-vault, git-repo, work-item) must pass — read interface returns schema-valid nodes/edges with provenance, closed edge vocabulary, queryByType subset consistency, and proposeWrite returns a schema-valid 'proposed' proposal that mutates nothing (proposals-only)."
186
+ },
187
+ {
188
+ "id": "knowledge.provider-health-pass-suite",
189
+ "path": "providers/health/health-pass.test.js",
190
+ "description": "Health-pass suite (issue #317, AC1+AC2): the same health command over vault + git-repo providers yields schema-valid reports (AC1); a backlog pass over the work-item adapter driven by a seeded fixture flags a duplicate and a broken blocker link (AC2) — never the real board."
156
191
  }
157
192
  ],
158
193
  "skills": [
@@ -0,0 +1,12 @@
1
+ # Fixture Context
2
+
3
+ ## Glossary
4
+
5
+ ### Widget
6
+
7
+ A demonstration domain noun used by the git-repo provider conformance fixture.
8
+ _Avoid_: Gadget
9
+
10
+ ### Sprocket
11
+
12
+ Another fixture vocabulary term with a short definition line.
@@ -0,0 +1,13 @@
1
+ ---
2
+ status: merged
3
+ subject: Old sprocket shape
4
+ decided: 2026-07-02
5
+ merged_into: sprocket-shape
6
+ evidence:
7
+ - kind: pr
8
+ ref: https://github.com/kontourai/flow-agents/pull/319
9
+ ---
10
+
11
+ # Old sprocket shape
12
+
13
+ Merged into [sprocket-shape](./sprocket-shape.md).
@@ -0,0 +1,14 @@
1
+ ---
2
+ status: current
3
+ subject: Sprocket shape
4
+ decided: 2026-07-02
5
+ supersedes:
6
+ - old-sprocket-shape
7
+ evidence:
8
+ - kind: pr
9
+ ref: https://github.com/kontourai/flow-agents/pull/319
10
+ ---
11
+
12
+ # Sprocket shape
13
+
14
+ Sprockets are round. Absorbs the old-sprocket-shape topic.
@@ -0,0 +1,14 @@
1
+ ---
2
+ status: current
3
+ subject: Widget format
4
+ decided: 2026-07-01
5
+ evidence:
6
+ - kind: issue
7
+ ref: https://github.com/kontourai/flow-agents/issues/317
8
+ - kind: doc
9
+ ref: docs/learnings/fixture-learning.md
10
+ ---
11
+
12
+ # Widget format
13
+
14
+ Widgets are stored as JSON. Lean rationale linked via evidence.
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: "Fixture Learning"
3
+ ---
4
+
5
+ # Fixture Learning
6
+
7
+ A durable learning used by the git-repo provider conformance fixture.
@@ -0,0 +1,30 @@
1
+ [
2
+ {
3
+ "number": 14,
4
+ "title": "Add dark mode toggle to settings",
5
+ "state": "OPEN",
6
+ "labels": [{ "name": "enhancement" }],
7
+ "body": "Users want a dark mode toggle in the settings screen.\n\n<!-- flow-agents:work-item-metadata\n{\n \"schema_version\": \"1.0\",\n \"blockers\": []\n}\n-->"
8
+ },
9
+ {
10
+ "number": 15,
11
+ "title": "Add dark-mode toggle to settings page",
12
+ "state": "OPEN",
13
+ "labels": [{ "name": "enhancement" }],
14
+ "body": "Duplicate of the dark mode toggle request. Settings page needs a dark-mode toggle."
15
+ },
16
+ {
17
+ "number": 20,
18
+ "title": "Ship theme system",
19
+ "state": "OPEN",
20
+ "labels": [{ "name": "feature" }],
21
+ "body": "Overarching theme work. Relates to #14.\n\n<!-- flow-agents:work-item-metadata\n{\n \"schema_version\": \"1.0\",\n \"blockers\": [\n { \"repo\": \"seed/fixture\", \"number\": 14 },\n { \"repo\": \"seed/fixture\", \"number\": 999 }\n ]\n}\n-->"
22
+ },
23
+ {
24
+ "number": 21,
25
+ "title": "Document theme tokens",
26
+ "state": "OPEN",
27
+ "labels": [],
28
+ "body": "Blocked by #20. Also see #14 for the toggle."
29
+ }
30
+ ]
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Knowledge Store Provider — Conformance Suite (issue #317, AC3).
3
+ *
4
+ * One parameterized suite every provider must pass. Proves that markdown-vault,
5
+ * git-repo, and work-item all satisfy the read + proposals-only interface and
6
+ * emit schema-valid nodes/edges/proposals. Run:
7
+ * node --test kits/knowledge/providers/conformance/suite.test.js
8
+ */
9
+
10
+ import { test, describe, before, after } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { MarkdownVaultProvider } from "../markdown-vault/index.js";
18
+ import { GitRepoProvider } from "../git-repo/index.js";
19
+ import { WorkItemProvider } from "../work-item/index.js";
20
+ import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
21
+ import { loadSchemas, EDGE_TYPES } from "../lib/model.js";
22
+ import { validate } from "../lib/schema-validate.js";
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const FIXTURES = path.join(__dirname, "fixtures");
26
+ const schemas = loadSchemas();
27
+
28
+ function assertSchema(value, schema, label) {
29
+ const { valid, errors } = validate(value, schema);
30
+ assert.ok(valid, `${label} not schema-valid:\n ${errors.join("\n ")}`);
31
+ }
32
+
33
+ // --- per-provider fixture builders --------------------------------------
34
+
35
+ async function buildVault() {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kg-vault-conf-"));
37
+ const store = new DefaultKnowledgeStore({ storeRoot: dir });
38
+ const rawId = await store.create({ type: "raw", title: "Raw source", body: "raw material", category: "eng", provenance: { agent: "test" } });
39
+ const compiledId = await store.create({ type: "compiled", title: "Compiled note", body: "distilled", category: "eng", provenance: { agent: "test" } });
40
+ await store.link(compiledId, [{ target_id: rawId, kind: "source" }], { agent: "test" });
41
+ const provider = new MarkdownVaultProvider({ store, storeRoot: dir, agent: "test" });
42
+ return { provider, proposeIntent: { title: "Proposed note", body: "content", category: "eng" }, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
43
+ }
44
+
45
+ async function buildGitRepo() {
46
+ const provider = new GitRepoProvider({ repoRoot: path.join(FIXTURES, "git-repo"), agent: "test" });
47
+ return { provider, proposeIntent: { subject: "Fixture decision", body: "the answer", evidence: [{ kind: "issue", ref: "#317" }] }, cleanup: () => {} };
48
+ }
49
+
50
+ async function buildWorkItem() {
51
+ const issues = fs.readFileSync(path.join(FIXTURES, "work-item", "issues.json"), "utf8");
52
+ const runner = async () => issues;
53
+ const provider = new WorkItemProvider({ repo: "seed/fixture", runner, agent: "test" });
54
+ return { provider, proposeIntent: { issue: 14, commentBody: "draft comment" }, cleanup: () => {} };
55
+ }
56
+
57
+ const PROVIDERS = [
58
+ { name: "markdown-vault", build: buildVault, expectedId: "markdown-vault" },
59
+ { name: "git-repo", build: buildGitRepo, expectedId: "git-repo" },
60
+ { name: "work-item", build: buildWorkItem, expectedId: "work-item" },
61
+ ];
62
+
63
+ for (const { name, build, expectedId } of PROVIDERS) {
64
+ describe(`conformance: ${name}`, () => {
65
+ let ctx;
66
+ before(async () => { ctx = await build(); });
67
+ after(() => ctx.cleanup());
68
+
69
+ test("capabilities() declares proposals-only, non-writable", () => {
70
+ const cap = ctx.provider.capabilities();
71
+ assert.equal(cap.id, expectedId);
72
+ assert.equal(cap.writable, false);
73
+ assert.equal(cap.write_mode, "proposals-only");
74
+ assert.ok(Array.isArray(cap.proposal_targets) && cap.proposal_targets.length > 0);
75
+ });
76
+
77
+ test("readNodes() returns schema-valid nodes with unique ids and provider provenance", async () => {
78
+ const nodes = await ctx.provider.readNodes();
79
+ assert.ok(Array.isArray(nodes) && nodes.length > 0, "expected at least one node");
80
+ const ids = new Set();
81
+ for (const n of nodes) {
82
+ assertSchema(n, schemas.node, `${name} node ${n.id}`);
83
+ assert.equal(n.provenance.provider, expectedId);
84
+ assert.ok(!ids.has(n.id), `duplicate node id ${n.id}`);
85
+ ids.add(n.id);
86
+ }
87
+ });
88
+
89
+ test("readEdges() returns schema-valid edges in the closed edge vocabulary", async () => {
90
+ const edges = await ctx.provider.readEdges();
91
+ assert.ok(Array.isArray(edges));
92
+ for (const e of edges) {
93
+ assertSchema(e, schemas.edge, `${name} edge ${e.id}`);
94
+ assert.ok(EDGE_TYPES.includes(e.type), `edge type ${e.type} outside closed vocabulary`);
95
+ assert.equal(e.provenance.provider, expectedId);
96
+ }
97
+ });
98
+
99
+ test("queryByType() is a consistent subset of readNodes()", async () => {
100
+ const nodes = await ctx.provider.readNodes();
101
+ const types = [...new Set(nodes.map((n) => n.type))];
102
+ for (const t of types) {
103
+ const q = await ctx.provider.queryByType(t);
104
+ assert.ok(q.every((n) => n.type === t), `queryByType(${t}) returned off-type node`);
105
+ assert.equal(q.length, nodes.filter((n) => n.type === t).length);
106
+ }
107
+ });
108
+
109
+ test("readGraph() returns { nodes, edges }", async () => {
110
+ const g = await ctx.provider.readGraph();
111
+ assert.ok(Array.isArray(g.nodes) && Array.isArray(g.edges));
112
+ });
113
+
114
+ test("proposeWrite() returns a schema-valid 'proposed' proposal and mutates nothing", async () => {
115
+ const before = await ctx.provider.readNodes();
116
+ const p = await ctx.provider.proposeWrite(ctx.proposeIntent);
117
+ assertSchema(p, schemas.proposal, `${name} proposal`);
118
+ assert.equal(p.status, "proposed");
119
+ assert.equal(p.provider, expectedId);
120
+ assert.equal(typeof p.rendered, "string");
121
+ const after = await ctx.provider.readNodes();
122
+ assert.equal(after.length, before.length, "proposeWrite must not add nodes to the store");
123
+ });
124
+ });
125
+ }