@kontourai/flow-agents 3.0.0 → 3.1.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.
- package/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +143 -0
- package/build/src/cli/assignment-provider.js +106 -49
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +392 -35
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/contracts/assignment-provider-contract.md +9 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +19 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +4 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +4 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +17 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/package.json +4 -2
- package/scripts/README.md +1 -0
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +137 -55
- package/src/cli/workflow-sidecar.ts +430 -33
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 2 — DISTILL.
|
|
3
|
+
*
|
|
4
|
+
* Turn the ingested residue into DRAFT deltas that validate BEFORE they are
|
|
5
|
+
* proposed (R2): decision-registry topic files (one per decision SUBJECT, the
|
|
6
|
+
* subject a CONTEXT.md vocabulary noun), CONTEXT.md vocabulary additions for any
|
|
7
|
+
* new subject noun, and docs/learnings entries. Nothing is written to disk here
|
|
8
|
+
* — distill returns draft objects; writing (only under <session>/proposals/)
|
|
9
|
+
* happens in the orchestrator.
|
|
10
|
+
*
|
|
11
|
+
* @module promote/distill
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { slugify, readGlossaryTerms, validateDecisionDelta } from "./lib.js";
|
|
15
|
+
|
|
16
|
+
function deriveSubject(decision) {
|
|
17
|
+
if (decision.subject) return decision.subject;
|
|
18
|
+
// Derive a rough noun-phrase subject from the outcome body (draft — the
|
|
19
|
+
// operator refines it at apply time). Take the leading clause, title-case.
|
|
20
|
+
const clause = String(decision.body || "").split(/[.;:]/)[0].trim().split(/\s+/).slice(0, 6).join(" ");
|
|
21
|
+
if (!clause) return "Delivered decision";
|
|
22
|
+
return clause.charAt(0).toUpperCase() + clause.slice(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} residue from ingestSession.
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {string} [options.repoRoot] repo whose CONTEXT.md is checked for vocabulary gaps.
|
|
29
|
+
* @param {string} [options.decided] ISO date to stamp on drafts (default: today).
|
|
30
|
+
* @returns {{ decisions: object[], vocabulary: object[], learnings: object[], warnings: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
export function distill(residue, options = {}) {
|
|
33
|
+
const decided = options.decided || new Date().toISOString().slice(0, 10);
|
|
34
|
+
const glossary = options.repoRoot ? readGlossaryTerms(options.repoRoot) : new Set();
|
|
35
|
+
const warnings = [];
|
|
36
|
+
|
|
37
|
+
const decisions = [];
|
|
38
|
+
const vocabulary = [];
|
|
39
|
+
const seenSlugs = new Set();
|
|
40
|
+
const seenVocab = new Set();
|
|
41
|
+
|
|
42
|
+
for (const raw of residue.decisions || []) {
|
|
43
|
+
const subject = deriveSubject(raw);
|
|
44
|
+
const slug = slugify(subject);
|
|
45
|
+
if (!slug || seenSlugs.has(slug)) continue;
|
|
46
|
+
seenSlugs.add(slug);
|
|
47
|
+
|
|
48
|
+
// Base provenance so the draft is already schema-valid at distill time
|
|
49
|
+
// (R2: validate BEFORE proposing). link() enriches with PR + merge SHA.
|
|
50
|
+
const evidence = [
|
|
51
|
+
{ kind: "session-archive", ref: `${residue.sessionDir}/${residue.sessionMarkdown || "state.json"}` },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const newVocabulary = !glossary.has(subject.toLowerCase());
|
|
55
|
+
const delta = {
|
|
56
|
+
subject,
|
|
57
|
+
slug,
|
|
58
|
+
status: "current",
|
|
59
|
+
decided,
|
|
60
|
+
body: raw.body || subject,
|
|
61
|
+
evidence,
|
|
62
|
+
derived: !!raw.derived,
|
|
63
|
+
newVocabulary,
|
|
64
|
+
};
|
|
65
|
+
const errors = validateDecisionDelta(delta);
|
|
66
|
+
if (errors.length) {
|
|
67
|
+
warnings.push(`decision draft '${slug}' failed pre-proposal validation: ${errors.join("; ")}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
decisions.push(delta);
|
|
71
|
+
|
|
72
|
+
if (newVocabulary && !seenVocab.has(slug)) {
|
|
73
|
+
seenVocab.add(slug);
|
|
74
|
+
vocabulary.push({
|
|
75
|
+
term: subject,
|
|
76
|
+
slug,
|
|
77
|
+
definition: (raw.body || subject).split(/[.;]/)[0].trim(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const learnings = (residue.learnings || []).map((rec, i) => ({
|
|
83
|
+
id: rec.id || `learn-${residue.slug}-${i + 1}`,
|
|
84
|
+
title: rec.summary ? rec.summary.split(/[.;]/)[0].trim() : `Learning ${i + 1}`,
|
|
85
|
+
body: rec.summary || "",
|
|
86
|
+
outcome: rec.outcome || null,
|
|
87
|
+
source_refs: Array.isArray(rec.source_refs) ? rec.source_refs : [],
|
|
88
|
+
correction: rec.correction && rec.correction.needed ? {
|
|
89
|
+
type: rec.correction.type || null,
|
|
90
|
+
recurrence_key: rec.correction.recurrence_key || null,
|
|
91
|
+
prevention: rec.correction.prevention || null,
|
|
92
|
+
} : null,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
return { decisions, vocabulary, learnings, warnings };
|
|
96
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Cache eviction policy
|
|
4
|
+
decided: 2026-06-10
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/401
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Cache eviction policy
|
|
11
|
+
|
|
12
|
+
Entries are evicted using a least-recently-used (LRU) policy.
|
package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Cache eviction policy
|
|
4
|
+
decided: 2026-06-25
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/415
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Cache eviction policy
|
|
11
|
+
|
|
12
|
+
Entries are evicted using a least-frequently-used (LFU) policy.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Liveness Claim
|
|
4
|
+
decided: 2026-06-20
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/166
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Liveness Claim
|
|
11
|
+
|
|
12
|
+
Coordination is an advisory TTL-bounded liveness claim, not a hard lock.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"ts":"2026-07-02T19:55:00Z","event":"plan","summary":"actor struct + resolver plan"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"ts":"2026-07-02T22:10:00Z","event":"verify","summary":"test_actor_identity.sh green; distinct actors confirmed"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"ts":"2026-07-02T20:30:00Z","event":"implement","summary":"actor resolver + fail-closed liveness write"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# kontourai-flow-agents-287
|
|
2
|
+
|
|
3
|
+
branch: agent/287-actor-identity
|
|
4
|
+
worktree: /tmp/wt-287
|
|
5
|
+
created: 2026-07-02T19:35:52Z
|
|
6
|
+
status: delivered
|
|
7
|
+
type: deliver
|
|
8
|
+
iteration: 3
|
|
9
|
+
|
|
10
|
+
## Plan
|
|
11
|
+
|
|
12
|
+
Actor struct {runtime, session_id, host, human?} with a stable serialized id; SessionStart derives FLOW_AGENTS_ACTOR; the sidecar fails loudly on a missing/local actor for liveness writes.
|
|
13
|
+
|
|
14
|
+
## Definition Of Done
|
|
15
|
+
|
|
16
|
+
- **User outcome:** Actor identity is a stable serialized struct so two concurrent sessions on one host never collide in the liveness stream.
|
|
17
|
+
- **Scope:** Workflow session artifacts and sidecars.
|
|
18
|
+
- **Acceptance criteria:**
|
|
19
|
+
- [x] Two concurrent sessions on one host produce distinct actors; liveness status shows two holders; an unset actor on a liveness write fails with remediation.
|
|
20
|
+
- **Durable docs target:** docs/decisions (actor identity)
|
|
21
|
+
- **Sandbox mode:** local-edit
|
|
22
|
+
|
|
23
|
+
## Decisions
|
|
24
|
+
|
|
25
|
+
- **Actor identity** — Agent identity is a serialized struct {runtime, session_id, host, human?} with a stable id; the sidecar fails closed (loud) on a missing or `local` actor for any liveness write, so concurrent same-host sessions never collide.
|
|
26
|
+
|
|
27
|
+
## Execution Progress
|
|
28
|
+
|
|
29
|
+
- [x] Actor struct + resolver implemented; SessionStart derives FLOW_AGENTS_ACTOR.
|
|
30
|
+
- [x] Liveness writes fail closed on unset/local actor.
|
|
31
|
+
|
|
32
|
+
## Verification Report
|
|
33
|
+
|
|
34
|
+
Build: [PASS]
|
|
35
|
+
|
|
36
|
+
### Acceptance Criteria
|
|
37
|
+
- [PASS] Two concurrent sessions produce distinct actors; unset actor fails loud - Evidence: integration eval test_actor_identity.sh.
|
|
38
|
+
|
|
39
|
+
### Verdict: PASS
|
|
40
|
+
|
|
41
|
+
## Goal Fit Gate
|
|
42
|
+
|
|
43
|
+
- [x] Original user goal restated and met.
|
|
44
|
+
|
|
45
|
+
## Final Acceptance
|
|
46
|
+
|
|
47
|
+
- [x] CI/relevant checks passed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"task_slug": "kontourai-flow-agents-287",
|
|
4
|
+
"repo": "kontourai/flow-agents",
|
|
5
|
+
"status": "learned",
|
|
6
|
+
"updated_at": "2026-07-02T22:40:06Z",
|
|
7
|
+
"records": [
|
|
8
|
+
{
|
|
9
|
+
"recorded_at": "2026-07-02T22:40:06Z",
|
|
10
|
+
"id": "learn-287-ci-eval-gap",
|
|
11
|
+
"outcome": "mixed",
|
|
12
|
+
"summary": "Integration eval suite not wired into CI; resume-roundtrip eval broken at baseline invisibly. Manifest registration is what makes agent evidence reconcilable.",
|
|
13
|
+
"source_refs": ["evals/run.sh", "evals/ci/run-baseline.sh"],
|
|
14
|
+
"correction": {
|
|
15
|
+
"needed": true,
|
|
16
|
+
"type": "tooling",
|
|
17
|
+
"recurrence_key": "integration-evals-not-in-ci",
|
|
18
|
+
"prevention": { "target": "backlog", "action": "wire integration evals into CI required lanes", "status": "completed" }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"recorded_at": "2026-07-02T22:40:06Z",
|
|
23
|
+
"id": "learn-287-env-drift",
|
|
24
|
+
"outcome": "mixed",
|
|
25
|
+
"summary": "node_modules drifted from lockfile: phantom eval failures and record-evidence hard-blocked until npm ci.",
|
|
26
|
+
"source_refs": ["package-lock.json"],
|
|
27
|
+
"correction": {
|
|
28
|
+
"needed": true,
|
|
29
|
+
"type": "process",
|
|
30
|
+
"recurrence_key": "node-modules-lockfile-drift",
|
|
31
|
+
"prevention": { "target": "docs", "action": "document npm ci before verification phases", "status": "completed" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"task_slug": "kontourai-flow-agents-287",
|
|
4
|
+
"repo": "kontourai/flow-agents",
|
|
5
|
+
"status": "accepted",
|
|
6
|
+
"phase": "learning",
|
|
7
|
+
"created_at": "2026-07-02T19:50:44Z",
|
|
8
|
+
"updated_at": "2026-07-02T22:40:06Z",
|
|
9
|
+
"artifact_paths": [
|
|
10
|
+
"acceptance.json",
|
|
11
|
+
"kontourai-flow-agents-287--deliver.md",
|
|
12
|
+
"learning.json",
|
|
13
|
+
"state.json"
|
|
14
|
+
],
|
|
15
|
+
"next_action": {
|
|
16
|
+
"status": "continue",
|
|
17
|
+
"summary": "287 delivered clean after 2 fix iterations; process learnings routed"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 4 — HEALTH.
|
|
3
|
+
*
|
|
4
|
+
* Contradiction detection over the decision-registry topic files, via the #317
|
|
5
|
+
* knowledge-store provider interface. The git-repo provider is the SAME reader
|
|
6
|
+
* every provider shares (no forked reader): its graph is folded together with
|
|
7
|
+
* the proposed draft decisions, and the provider-agnostic duplicate-detection
|
|
8
|
+
* health verb flags topics whose subject nouns overlap. Where two CURRENT
|
|
9
|
+
* decisions collide over one subject with divergent content, the step emits a
|
|
10
|
+
* merge-repair PROPOSAL naming BOTH topics and a merge target.
|
|
11
|
+
*
|
|
12
|
+
* Merge-repair is a PROPOSAL, never an auto-edit. It is a schema-valid
|
|
13
|
+
* knowledge-store proposal (proposals-only by construction) the operator applies
|
|
14
|
+
* through the registry's own supersede/merge edit.
|
|
15
|
+
*
|
|
16
|
+
* @module promote/health
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { GitRepoProvider } from "../providers/git-repo/index.js";
|
|
20
|
+
import { detectDuplicates } from "../providers/health/index.js";
|
|
21
|
+
import { node, proposal, provenance } from "../providers/lib/model.js";
|
|
22
|
+
import { loadSchemas } from "../providers/lib/model.js";
|
|
23
|
+
import { assertValid } from "../providers/lib/schema-validate.js";
|
|
24
|
+
|
|
25
|
+
const { proposal: PROPOSAL_SCHEMA } = loadSchemas();
|
|
26
|
+
|
|
27
|
+
/** Build draft decision nodes (same id scheme as the git-repo provider) from linked deltas. */
|
|
28
|
+
function draftNodes(draftDecisions, agent) {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
return draftDecisions.map((d) =>
|
|
31
|
+
node({
|
|
32
|
+
id: `decision:${d.slug}`,
|
|
33
|
+
type: "decision",
|
|
34
|
+
title: d.subject,
|
|
35
|
+
body: d.body || "",
|
|
36
|
+
attributes: { registry_status: d.status || "current", draft: true },
|
|
37
|
+
provenance: provenance({ provider: "git-repo", source: `docs/decisions/${d.slug}.md`, locator: "draft", retrievedAt: now, agent }),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function nodeStatus(n) {
|
|
43
|
+
return (n.attributes && n.attributes.registry_status) || "current";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} options
|
|
48
|
+
* @param {string} options.repoRoot repo whose registry is read via the git-repo provider.
|
|
49
|
+
* @param {object[]} [options.draftDecisions] linked draft decision deltas to fold in.
|
|
50
|
+
* @param {string} [options.agent]
|
|
51
|
+
* @param {number} [options.threshold] duplicate similarity threshold (default 0.7).
|
|
52
|
+
* @returns {Promise<{report:object, mergeProposals:object[]}>}
|
|
53
|
+
*/
|
|
54
|
+
export async function health(options = {}) {
|
|
55
|
+
const { repoRoot, draftDecisions = [], agent, threshold = 0.7 } = options;
|
|
56
|
+
const provider = new GitRepoProvider({ repoRoot, agent });
|
|
57
|
+
const existing = await provider.readGraph();
|
|
58
|
+
|
|
59
|
+
const nodes = [...existing.nodes, ...draftNodes(draftDecisions, agent)];
|
|
60
|
+
const graph = { nodes, edges: existing.edges };
|
|
61
|
+
|
|
62
|
+
// Reuse the provider-agnostic duplicate-detection verb: overlapping subject
|
|
63
|
+
// nouns => a schema-valid duplicate-detection report naming BOTH topics.
|
|
64
|
+
const report = detectDuplicates(graph, { provider: "git-repo", threshold });
|
|
65
|
+
|
|
66
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
67
|
+
const mergeProposals = [];
|
|
68
|
+
|
|
69
|
+
for (const finding of report.findings) {
|
|
70
|
+
const [idA, idB] = finding.node_ids;
|
|
71
|
+
const a = byId.get(idA);
|
|
72
|
+
const b = byId.get(idB);
|
|
73
|
+
if (!a || !b || a.type !== "decision" || b.type !== "decision") continue;
|
|
74
|
+
// Contradiction = both current AND divergent content. A resolved pair (one
|
|
75
|
+
// already a merged/superseded tombstone) is not a contradiction.
|
|
76
|
+
const aCurrent = nodeStatus(a) === "current";
|
|
77
|
+
const bCurrent = nodeStatus(b) === "current";
|
|
78
|
+
if (!aCurrent || !bCurrent) continue;
|
|
79
|
+
if ((a.body || "").trim() === (b.body || "").trim()) continue;
|
|
80
|
+
|
|
81
|
+
// Merge target: keep the more-recently-decided topic; the other is folded in.
|
|
82
|
+
const decidedA = (a.attributes && a.attributes.decided) || "";
|
|
83
|
+
const decidedB = (b.attributes && b.attributes.decided) || "";
|
|
84
|
+
let survivor = a, absorbed = b;
|
|
85
|
+
if (decidedB > decidedA) { survivor = b; absorbed = a; }
|
|
86
|
+
else if (decidedB === decidedA && b.id < a.id) { survivor = b; absorbed = a; }
|
|
87
|
+
|
|
88
|
+
const survivorSlug = survivor.id.replace(/^decision:/, "");
|
|
89
|
+
const absorbedSlug = absorbed.id.replace(/^decision:/, "");
|
|
90
|
+
|
|
91
|
+
const rendered = [
|
|
92
|
+
"---",
|
|
93
|
+
"status: merged",
|
|
94
|
+
`subject: ${absorbed.title}`,
|
|
95
|
+
`decided: ${new Date().toISOString().slice(0, 10)}`,
|
|
96
|
+
`merged_into: ${survivorSlug}`,
|
|
97
|
+
"evidence:",
|
|
98
|
+
" - kind: doc",
|
|
99
|
+
` ref: docs/decisions/${survivorSlug}.md`,
|
|
100
|
+
"---",
|
|
101
|
+
"",
|
|
102
|
+
`# ${absorbed.title}`,
|
|
103
|
+
"",
|
|
104
|
+
`Merged into [${survivorSlug}](./${survivorSlug}.md). This subject was carried by two`,
|
|
105
|
+
`current topics with divergent content; the current answer now lives in ${survivorSlug}.`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
|
|
108
|
+
const prop = proposal({
|
|
109
|
+
provider: "git-repo",
|
|
110
|
+
kind: "decision-topic",
|
|
111
|
+
target: {
|
|
112
|
+
merge_repair: true,
|
|
113
|
+
merge_into: survivorSlug,
|
|
114
|
+
tombstone: absorbedSlug,
|
|
115
|
+
topics: [survivorSlug, absorbedSlug],
|
|
116
|
+
path: `docs/decisions/${absorbedSlug}.md`,
|
|
117
|
+
},
|
|
118
|
+
payload: {
|
|
119
|
+
contradiction: {
|
|
120
|
+
topics: [survivor.id, absorbed.id],
|
|
121
|
+
subject_a: survivor.title,
|
|
122
|
+
subject_b: absorbed.title,
|
|
123
|
+
divergence: { survivor: survivor.body || "", absorbed: absorbed.body || "" },
|
|
124
|
+
similarity: finding.evidence && finding.evidence.similarity,
|
|
125
|
+
},
|
|
126
|
+
merge_target: survivorSlug,
|
|
127
|
+
},
|
|
128
|
+
rendered,
|
|
129
|
+
rationale: `Two CURRENT decision topics ('${survivor.title}' @ ${survivor.id}, '${absorbed.title}' @ ${absorbed.id}) share a subject noun with divergent content — a registry contradiction. Proposed merge target: ${survivorSlug}; fold ${absorbedSlug} in as a merged tombstone. Merge-repair is a PROPOSAL: apply it via the registry's own edit, never auto-written.`,
|
|
130
|
+
provenance: provenance({ provider: "git-repo", source: `docs/decisions/${absorbedSlug}.md`, locator: "merge-repair", agent }),
|
|
131
|
+
});
|
|
132
|
+
assertValid(prop, PROPOSAL_SCHEMA, `merge-repair proposal (${absorbedSlug} -> ${survivorSlug})`);
|
|
133
|
+
mergeProposals.push(prop);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { report, mergeProposals };
|
|
137
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge promote sub-flow — orchestrator (the "flow within a flow").
|
|
3
|
+
*
|
|
4
|
+
* Runs ingest -> distill -> link -> health over a completed session directory
|
|
5
|
+
* and lands every output as a DRAFT under `<session>/proposals/` for the promote
|
|
6
|
+
* step's human/agent to apply. PROPOSALS-ONLY (R4): this module writes ONLY under
|
|
7
|
+
* `outDir` (default `<session>/proposals`) — never into docs/, CONTEXT.md, or any
|
|
8
|
+
* path outside the session directory. The Builder promote step names this sub-flow
|
|
9
|
+
* as its assisted path; the `promote` CLI stays the recording mechanism.
|
|
10
|
+
*
|
|
11
|
+
* FlowDefinition: kits/knowledge/flows/promote.flow.json (id `knowledge.promote`).
|
|
12
|
+
* See context/contracts/knowledge-store-contract.md and
|
|
13
|
+
* context/contracts/decision-registry-contract.md.
|
|
14
|
+
*
|
|
15
|
+
* @module promote
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { ingestSession } from "./ingest.js";
|
|
21
|
+
import { distill } from "./distill.js";
|
|
22
|
+
import { link } from "./link.js";
|
|
23
|
+
import { health } from "./health.js";
|
|
24
|
+
|
|
25
|
+
export { ingestSession } from "./ingest.js";
|
|
26
|
+
export { distill } from "./distill.js";
|
|
27
|
+
export { link } from "./link.js";
|
|
28
|
+
export { health } from "./health.js";
|
|
29
|
+
export { snapshotDir, validateDecisionDelta } from "./lib.js";
|
|
30
|
+
|
|
31
|
+
function renderVocabulary(vocabulary) {
|
|
32
|
+
const lines = [
|
|
33
|
+
"# Proposed CONTEXT.md vocabulary additions",
|
|
34
|
+
"",
|
|
35
|
+
"Add each subject noun to the `## Glossary` section of CONTEXT.md before its",
|
|
36
|
+
"decision topic is filed (the registry slug rule keys off CONTEXT.md vocabulary).",
|
|
37
|
+
"",
|
|
38
|
+
];
|
|
39
|
+
for (const v of vocabulary) {
|
|
40
|
+
lines.push(`### ${v.term}`, "", v.definition || "(define this term)", "");
|
|
41
|
+
}
|
|
42
|
+
if (vocabulary.length === 0) lines.push("_No new vocabulary — every decision subject already exists in CONTEXT.md._", "");
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderLearning(rec) {
|
|
47
|
+
const lines = [
|
|
48
|
+
"---",
|
|
49
|
+
`id: ${rec.id}`,
|
|
50
|
+
rec.outcome ? `outcome: ${rec.outcome}` : "outcome: neutral",
|
|
51
|
+
"---",
|
|
52
|
+
"",
|
|
53
|
+
`# ${rec.title}`,
|
|
54
|
+
"",
|
|
55
|
+
rec.body || "",
|
|
56
|
+
"",
|
|
57
|
+
];
|
|
58
|
+
if (rec.source_refs && rec.source_refs.length) {
|
|
59
|
+
lines.push("## Source refs", "");
|
|
60
|
+
for (const r of rec.source_refs) lines.push(`- ${r}`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
}
|
|
63
|
+
if (rec.correction) {
|
|
64
|
+
lines.push("## Correction", "", `- type: ${rec.correction.type || "n/a"}`, `- recurrence_key: ${rec.correction.recurrence_key || "n/a"}`, "");
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderReadme(result) {
|
|
70
|
+
return [
|
|
71
|
+
`# Promote proposals — session ${result.slug}`,
|
|
72
|
+
"",
|
|
73
|
+
"Generated by the Knowledge Kit promote sub-flow (`knowledge.promote`).",
|
|
74
|
+
"Everything here is a DRAFT PROPOSAL. Nothing was written to docs/ or CONTEXT.md.",
|
|
75
|
+
"The promote step's human/agent applies these, then records them with the `promote` CLI.",
|
|
76
|
+
"",
|
|
77
|
+
"## Apply order",
|
|
78
|
+
"",
|
|
79
|
+
"1. `vocabulary.md` — add any new subject nouns to CONTEXT.md `## Glossary`.",
|
|
80
|
+
"2. `decisions/*.md` — file each as `docs/decisions/<slug>.md`, then `npm run gen:decisions-index` + `npm run check:decisions`.",
|
|
81
|
+
"3. `learnings/*.md` — record via the learning sink / `docs/learnings/`.",
|
|
82
|
+
"4. `health/` — if a contradiction report + merge-repair proposal is present, apply the merge target edit.",
|
|
83
|
+
"",
|
|
84
|
+
"## Contents",
|
|
85
|
+
"",
|
|
86
|
+
`- decisions: ${result.decisions.map((d) => d.slug).join(", ") || "(none)"}`,
|
|
87
|
+
`- vocabulary additions: ${result.vocabulary.map((v) => v.term).join(", ") || "(none)"}`,
|
|
88
|
+
`- learnings: ${result.learnings.length}`,
|
|
89
|
+
`- touched topics: ${result.manifest.touched_topics.join(", ") || "(none)"}`,
|
|
90
|
+
`- contradiction findings: ${result.health.report.summary.finding_count}`,
|
|
91
|
+
`- merge-repair proposals: ${result.health.mergeProposals.length}`,
|
|
92
|
+
"",
|
|
93
|
+
].join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Write every draft ONLY under outDir. Returns the list of relative paths written. */
|
|
97
|
+
function writeProposals(outDir, linked, healthResult, result) {
|
|
98
|
+
const written = [];
|
|
99
|
+
const write = (rel, content) => {
|
|
100
|
+
const abs = path.join(outDir, rel);
|
|
101
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
102
|
+
fs.writeFileSync(abs, content, "utf8");
|
|
103
|
+
written.push(rel);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const d of linked.decisions) write(path.join("decisions", `${d.slug}.md`), d.rendered);
|
|
107
|
+
write("vocabulary.md", renderVocabulary(linked.vocabulary));
|
|
108
|
+
for (const rec of linked.learnings) write(path.join("learnings", `${rec.id}.md`), renderLearning(rec));
|
|
109
|
+
write("link-manifest.json", `${JSON.stringify(linked.manifest, null, 2)}\n`);
|
|
110
|
+
write(path.join("health", "contradiction-report.json"), `${JSON.stringify(healthResult.report, null, 2)}\n`);
|
|
111
|
+
if (healthResult.mergeProposals.length) {
|
|
112
|
+
write(path.join("health", "merge-proposals.json"), `${JSON.stringify(healthResult.mergeProposals, null, 2)}\n`);
|
|
113
|
+
for (const mp of healthResult.mergeProposals) {
|
|
114
|
+
write(path.join("health", `merge-repair-${mp.target.tombstone}.md`), mp.rendered);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
write("promote-result.json", `${JSON.stringify(result, null, 2)}\n`);
|
|
118
|
+
write("README.md", renderReadme(result));
|
|
119
|
+
return written;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run the promote sub-flow over a completed session directory.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} opts
|
|
126
|
+
* @param {string} opts.sessionDir absolute path to the completed session dir.
|
|
127
|
+
* @param {string} opts.repoRoot repo root (registry + CONTEXT.md live here).
|
|
128
|
+
* @param {object} [opts.provenance] { pr, mergeSha, sessionArchivePath }.
|
|
129
|
+
* @param {string} [opts.outDir] proposals dir (default <sessionDir>/proposals).
|
|
130
|
+
* @param {string} [opts.decided] ISO date stamped on drafts.
|
|
131
|
+
* @param {string} [opts.agent]
|
|
132
|
+
* @param {boolean} [opts.write=true] write drafts to disk (still only under outDir).
|
|
133
|
+
* @returns {Promise<object>} the promote result.
|
|
134
|
+
*/
|
|
135
|
+
export async function runPromote(opts = {}) {
|
|
136
|
+
const { sessionDir, repoRoot, provenance = {}, decided, agent, write = true } = opts;
|
|
137
|
+
if (!sessionDir) throw new Error("runPromote requires { sessionDir }");
|
|
138
|
+
if (!repoRoot) throw new Error("runPromote requires { repoRoot }");
|
|
139
|
+
const outDir = opts.outDir || path.join(sessionDir, "proposals");
|
|
140
|
+
|
|
141
|
+
const residue = ingestSession(sessionDir, { agent });
|
|
142
|
+
const distilled = distill(residue, { repoRoot, decided });
|
|
143
|
+
const linked = await link(distilled, provenance, { repoRoot, agent });
|
|
144
|
+
const healthResult = await health({ repoRoot, draftDecisions: linked.decisions, agent });
|
|
145
|
+
|
|
146
|
+
const result = {
|
|
147
|
+
schema_version: "1.0",
|
|
148
|
+
flow: "knowledge.promote",
|
|
149
|
+
slug: residue.slug,
|
|
150
|
+
repo: residue.repo,
|
|
151
|
+
out_dir: outDir,
|
|
152
|
+
ingested: {
|
|
153
|
+
session_markdown: residue.sessionMarkdown,
|
|
154
|
+
status: residue.status,
|
|
155
|
+
decision_candidates: residue.decisions.length,
|
|
156
|
+
learning_records: residue.learnings.length,
|
|
157
|
+
transcript_refs: residue.transcriptRefs,
|
|
158
|
+
touched_files: residue.touchedFiles,
|
|
159
|
+
},
|
|
160
|
+
decisions: linked.decisions.map((d) => ({ slug: d.slug, subject: d.subject, evidence: d.evidence, new_vocabulary: d.newVocabulary })),
|
|
161
|
+
vocabulary: linked.vocabulary,
|
|
162
|
+
learnings: linked.learnings,
|
|
163
|
+
manifest: linked.manifest,
|
|
164
|
+
health: {
|
|
165
|
+
report: healthResult.report,
|
|
166
|
+
mergeProposals: healthResult.mergeProposals,
|
|
167
|
+
},
|
|
168
|
+
warnings: distilled.warnings,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const written = write ? writeProposals(outDir, linked, healthResult, result) : [];
|
|
172
|
+
result.written = written;
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default runPromote;
|