@kontourai/flow-agents 3.0.0 → 3.2.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 +12 -0
- package/CHANGELOG.md +32 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +188 -0
- package/build/src/cli/assignment-provider.js +193 -51
- package/build/src/cli/workflow-sidecar.d.ts +16 -6
- package/build/src/cli/workflow-sidecar.js +492 -45
- 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 +10 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +24 -4
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +262 -5
- 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 +113 -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 +6 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- 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 +46 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +10 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +69 -11
- package/kits/builder/skills/design-probe/SKILL.md +47 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +147 -18
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
- package/kits/knowledge/adapters/shared/codec.js +265 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +517 -7
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -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/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +24 -4
- 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 +262 -5
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +239 -59
- package/src/cli/workflow-sidecar.ts +529 -43
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -18,10 +18,24 @@
|
|
|
18
18
|
* the actor-scoped stream has no shared-write race.
|
|
19
19
|
*
|
|
20
20
|
* Hot-path ordering + bounded read (F3, #288 fix iteration 1, sec-MED +
|
|
21
|
-
* cr-MED): steps run enabled? ->
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* cr-MED; restored by F4, fix-plan iteration 1): steps run enabled? ->
|
|
22
|
+
* mightHaveActiveSession() peek -> resolveActor() -> current.json
|
|
23
|
+
* active_slug present? -> bounded tail read, in that order, so a repo with
|
|
24
|
+
* liveness disabled OR no active session never pays the `resolveActor()`
|
|
25
|
+
* process-ancestry `ps` spawn cost. (#291 initially moved resolveActor()
|
|
26
|
+
* ahead of the active_slug check unconditionally, since the current-pointer
|
|
27
|
+
* read became actor-aware and needs the resolved actor to choose per-actor
|
|
28
|
+
* vs. legacy `current.json` — this silently dropped the "no active session"
|
|
29
|
+
* half of the short-circuit for every liveness-enabled call, not just the
|
|
30
|
+
* "disabled" half. F4 restores it: `mightHaveActiveSession()` is a plain,
|
|
31
|
+
* actor-free `fs.existsSync` peek of the legacy `current.json` file and the
|
|
32
|
+
* `current/` per-actor directory — when NEITHER exists, no actor could
|
|
33
|
+
* possibly have an active session (per-actor files only ever live under
|
|
34
|
+
* `current/`), so resolveActor() is skipped entirely, exactly restoring the
|
|
35
|
+
* pre-#291 short-circuit for the common "enabled, no session yet" case. Only
|
|
36
|
+
* once a current-pointer file might exist does resolveActor() run, still
|
|
37
|
+
* exactly once, reused for both the actor-scoped current-pointer read and the
|
|
38
|
+
* actor-unresolved gate below.)
|
|
25
39
|
*
|
|
26
40
|
* Orphan-heartbeat invariant (F8(ii), #288 fix iteration 2): a heartbeat must
|
|
27
41
|
* never be treated as its own evidence of a claim. The reviewer-reproduced
|
|
@@ -74,12 +88,12 @@
|
|
|
74
88
|
|
|
75
89
|
const fs = require('fs');
|
|
76
90
|
const path = require('path');
|
|
77
|
-
|
|
78
91
|
const { isLivenessEnabled, resolveHeartbeatThrottleSeconds } = require('./liveness-policy');
|
|
79
92
|
const { livenessStreamFile, appendLivenessEvent } = require('./liveness-write');
|
|
80
93
|
const { readLivenessEvents, readLivenessEventsTail, freshHolders } = require('./liveness-read');
|
|
81
94
|
const { resolveActor, isUnresolvedActor, sanitizeSegment } = require('./actor-identity');
|
|
82
95
|
const { flowAgentsArtifactRoot } = require('./local-artifact-paths');
|
|
96
|
+
const { readCurrentPointer } = require('./current-pointer');
|
|
83
97
|
|
|
84
98
|
/**
|
|
85
99
|
* Resolve a caller-supplied `now` (Date, ISO string, or omitted) to epoch ms.
|
|
@@ -101,35 +115,50 @@ function resolveNowMs(now) {
|
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
* Read
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
118
|
+
* Read the active_slug from the actor-scoped "current" pointer (#291: per-actor
|
|
119
|
+
* `current/<actor>.json` preferred over the legacy global `current.json`, via
|
|
120
|
+
* `readCurrentPointer` — the single choke point for that preference rule), sanitized through the
|
|
121
|
+
* same charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
|
|
122
|
+
* sec-LOW: this is a local file that could be hand-edited or otherwise hostile — `active_slug`
|
|
123
|
+
* must never be trusted verbatim before it is used as a JSONL grouping key / emitted `subjectId`
|
|
124
|
+
* or compared against event data). Tolerates a missing file or malformed JSON (returns "") exactly
|
|
125
|
+
* like the plain-`fs.readFileSync` read this replaces; when `actorKey` is empty/unresolved,
|
|
126
|
+
* `readCurrentPointer` falls straight to the legacy global file, so behavior for that case is
|
|
127
|
+
* unchanged from before #291.
|
|
110
128
|
*
|
|
111
129
|
* @param {string} root
|
|
130
|
+
* @param {string} [actorKey]
|
|
112
131
|
* @returns {string}
|
|
113
132
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
/**
|
|
134
|
+
* F4 fix (fix-plan iteration 1, MED): a cheap, actor-free existence peek restoring the module's
|
|
135
|
+
* documented "no active session never pays resolveActor()" invariant. `readCurrentPointer`'s
|
|
136
|
+
* actor-aware preference genuinely needs a resolved actor to choose per-actor vs. legacy — but
|
|
137
|
+
* that choice is only ever relevant when SOME current-pointer file might exist at all. When
|
|
138
|
+
* neither the legacy global `<root>/current.json` file NOR the `<root>/current/` per-actor
|
|
139
|
+
* directory exists, there is provably no active session for any actor (per-actor files are only
|
|
140
|
+
* ever written into that directory — see current-pointer.js's writePerActorCurrent), so this
|
|
141
|
+
* function returns false without calling resolveActor() at all, exactly restoring the pre-#291
|
|
142
|
+
* short-circuit for the common "liveness enabled, no session yet" case. Two plain fs.existsSync
|
|
143
|
+
* calls — no ps/proc ancestry cost, no JSON parse.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} root
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
function mightHaveActiveSession(root) {
|
|
149
|
+
return fs.existsSync(path.join(root, 'current.json')) || fs.existsSync(path.join(root, 'current'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readActiveSlug(root, actorKey) {
|
|
153
|
+
const { payload: current } = readCurrentPointer(root, actorKey);
|
|
154
|
+
if (!current) return '';
|
|
155
|
+
const activeSlug = (current && current.active_slug) || '';
|
|
156
|
+
if (!activeSlug) return '';
|
|
157
|
+
// sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
|
|
158
|
+
// fallback is not a legitimate slug, so treat it as "no active slug" here rather than
|
|
159
|
+
// matching against a subject that was never really claimed.
|
|
160
|
+
const sanitized = sanitizeSegment(activeSlug);
|
|
161
|
+
return sanitized === 'unknown' ? '' : sanitized;
|
|
133
162
|
}
|
|
134
163
|
|
|
135
164
|
/**
|
|
@@ -221,9 +250,10 @@ function attachConflictField(result, conflict) {
|
|
|
221
250
|
* object, never throws.
|
|
222
251
|
*
|
|
223
252
|
* See the module header for the full F8(ii) throttle/emit split. Summary:
|
|
224
|
-
* - enabled? ->
|
|
225
|
-
*
|
|
226
|
-
*
|
|
253
|
+
* - enabled? -> mightHaveActiveSession() peek -> resolveActor() -> slug
|
|
254
|
+
* present? -> bounded tail read, in that order (F3, restored by F4), so
|
|
255
|
+
* a disabled repo or one with no active session never pays the
|
|
256
|
+
* resolveActor() ancestry `ps` spawn cost.
|
|
227
257
|
* - THROTTLE decision reads only the bounded tail (never a full read):
|
|
228
258
|
* a release found there refuses; a fresh event there throttles.
|
|
229
259
|
* - EMIT decision requires an actual `claim` event for the pair: taken
|
|
@@ -248,12 +278,22 @@ function maybeEmitHeartbeat(opts = {}) {
|
|
|
248
278
|
}
|
|
249
279
|
|
|
250
280
|
const root = flowAgentsArtifactRoot(cwd);
|
|
251
|
-
|
|
252
|
-
|
|
281
|
+
// F4 fix (fix-plan iteration 1, MED): restore the "no active session never pays
|
|
282
|
+
// resolveActor()" invariant. mightHaveActiveSession() is a plain fs.existsSync peek of the
|
|
283
|
+
// LEGACY current.json / current/ dir — no actor needed — so a repo with liveness enabled but
|
|
284
|
+
// no session at all (the common case this invariant exists for) short-circuits here, exactly
|
|
285
|
+
// like pre-#291. Only once a current-pointer file MIGHT exist do we pay for resolveActor(),
|
|
286
|
+
// needed to decide per-actor vs. legacy preference via readActiveSlug/readCurrentPointer.
|
|
287
|
+
if (!mightHaveActiveSession(root)) {
|
|
253
288
|
return { emitted: false, reason: 'no-current' };
|
|
254
289
|
}
|
|
255
290
|
|
|
256
291
|
const { actor } = resolveActor(env);
|
|
292
|
+
const slug = readActiveSlug(root, actor);
|
|
293
|
+
if (!slug) {
|
|
294
|
+
return { emitted: false, reason: 'no-current' };
|
|
295
|
+
}
|
|
296
|
+
|
|
257
297
|
if (isUnresolvedActor(actor)) {
|
|
258
298
|
return { emitted: false, reason: 'actor-unresolved' };
|
|
259
299
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
'use strict';
|
|
26
26
|
|
|
27
27
|
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
28
29
|
const path = require('path');
|
|
29
30
|
const { spawnSync } = require('child_process');
|
|
30
31
|
const crypto = require('crypto');
|
|
@@ -42,6 +43,8 @@ const {
|
|
|
42
43
|
flowAgentsArtifactRoot,
|
|
43
44
|
flowAgentsArtifactRootsForRead,
|
|
44
45
|
} = require('./lib/local-artifact-paths');
|
|
46
|
+
const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
|
|
47
|
+
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
45
48
|
|
|
46
49
|
const MAX_STDIN = 1024 * 1024;
|
|
47
50
|
const ACTIVE_STATUSES = new Set([
|
|
@@ -1531,7 +1534,7 @@ async function bundleEnforcement(artifactDir, activeFlowStep) {
|
|
|
1531
1534
|
* to scanning all of .kontourai/flow-agents (newest-mtime).
|
|
1532
1535
|
*/
|
|
1533
1536
|
function preferredArtifactDir(flowAgentsDir) {
|
|
1534
|
-
const current =
|
|
1537
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
|
|
1535
1538
|
if (!current) return null;
|
|
1536
1539
|
const slug = current.artifact_dir || current.active_slug;
|
|
1537
1540
|
if (typeof slug !== 'string' || !slug.trim()) return null;
|
|
@@ -1549,7 +1552,7 @@ function hasSidecarPresence(artifactDir) {
|
|
|
1549
1552
|
// return that slug so analyze() can log the staleness rather than silently falling back to
|
|
1550
1553
|
// a global mtime scan that could resurface an abandoned/never-real session as active.
|
|
1551
1554
|
function staleCurrentSlug(flowAgentsDir) {
|
|
1552
|
-
const current =
|
|
1555
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, resolveActor(process.env).actor);
|
|
1553
1556
|
if (!current) return null;
|
|
1554
1557
|
const slug = current.artifact_dir || current.active_slug;
|
|
1555
1558
|
if (typeof slug !== 'string' || !slug.trim()) return null;
|
|
@@ -1669,7 +1672,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1669
1672
|
artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
|
|
1670
1673
|
}
|
|
1671
1674
|
|
|
1672
|
-
if (artifacts.length === 0) return { warnings: [], blocking: false };
|
|
1675
|
+
if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
|
|
1673
1676
|
|
|
1674
1677
|
const latest = artifacts[0];
|
|
1675
1678
|
const latestArtifactDir = path.dirname(latest.file);
|
|
@@ -1743,7 +1746,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1743
1746
|
if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
|
|
1744
1747
|
return blockRe.test(w);
|
|
1745
1748
|
});
|
|
1746
|
-
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
|
|
1749
|
+
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
|
|
1747
1750
|
}
|
|
1748
1751
|
|
|
1749
1752
|
/**
|
|
@@ -1842,12 +1845,266 @@ function remediationFor(warning) {
|
|
|
1842
1845
|
return null;
|
|
1843
1846
|
}
|
|
1844
1847
|
|
|
1848
|
+
// ─── #292 Wave 2: Stop-hook non-terminal release-with-handoff ────────────────
|
|
1849
|
+
//
|
|
1850
|
+
// When a session's Stop event fires and the session's state.json status is NOT
|
|
1851
|
+
// one of workflow-sidecar.ts's LIVENESS_TERMINAL statuses (delivered/accepted/
|
|
1852
|
+
// archived — the SAME set advance-state's terminal path already tests against,
|
|
1853
|
+
// imported here rather than re-declared, per AC8), the liveness claim and (for
|
|
1854
|
+
// the local-file assignment provider) the durable assignment claim would
|
|
1855
|
+
// otherwise sit held until TTL, even though the session has genuinely ended.
|
|
1856
|
+
// This closes that gap: always emit a provider-agnostic liveness release (AC1),
|
|
1857
|
+
// and for local-file additionally perform the real release inline (AC2), while
|
|
1858
|
+
// honestly disclosing (never executing) the equivalent GitHub-provider release
|
|
1859
|
+
// as a pending handoff.json intent (AC3). See the plan artifact
|
|
1860
|
+
// (.kontourai/flow-agents/kontourai-flow-agents-292/kontourai-flow-agents-292--plan-work.md,
|
|
1861
|
+
// "Wave 2") for the full design rationale and the render-don't-execute
|
|
1862
|
+
// constraint this deliberately does not cross.
|
|
1863
|
+
//
|
|
1864
|
+
// Fail-open (AC7): the ENTIRE body runs under one try/catch. Any failure —
|
|
1865
|
+
// missing build/, corrupt assignment record, unresolved actor, missing/
|
|
1866
|
+
// malformed provider settings — degrades to a single stderr diagnostic and
|
|
1867
|
+
// returns, never throwing, never affecting the Stop hook's own exit code or
|
|
1868
|
+
// goal-fit's warnings/blocking output (this function's return value is not
|
|
1869
|
+
// consumed by that contract at all — it is a pure side effect).
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* require() the compiled workflow-sidecar.js the same hasBuild/fs.existsSync-guarded
|
|
1873
|
+
* way loadActiveFlowStep() already requires flow-resolver.js. Returns null (never
|
|
1874
|
+
* throws) when build/ is absent or the require fails — the caller treats null as
|
|
1875
|
+
* "the LIVENESS_TERMINAL boundary is unknown; do not guess it" and skips the whole
|
|
1876
|
+
* release (fail-open, never a re-derived duplicate Set — AC8).
|
|
1877
|
+
*/
|
|
1878
|
+
function loadWorkflowSidecarBuilt() {
|
|
1879
|
+
const packageRoot = path.resolve(__dirname, '..', '..');
|
|
1880
|
+
const builtSidecar = path.join(packageRoot, 'build', 'src', 'cli', 'workflow-sidecar.js');
|
|
1881
|
+
if (!fs.existsSync(builtSidecar)) return null; // hasBuild guard
|
|
1882
|
+
try {
|
|
1883
|
+
const mod = require(builtSidecar);
|
|
1884
|
+
if (!(mod.LIVENESS_TERMINAL instanceof Set)) return null;
|
|
1885
|
+
return mod;
|
|
1886
|
+
} catch {
|
|
1887
|
+
return null; // require failed — fail-open
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/** Same hasBuild-guarded require idiom, for build/src/cli/assignment-provider.js. */
|
|
1892
|
+
function loadAssignmentProviderBuilt() {
|
|
1893
|
+
const packageRoot = path.resolve(__dirname, '..', '..');
|
|
1894
|
+
const builtProvider = path.join(packageRoot, 'build', 'src', 'cli', 'assignment-provider.js');
|
|
1895
|
+
if (!fs.existsSync(builtProvider)) return null;
|
|
1896
|
+
try {
|
|
1897
|
+
const mod = require(builtProvider);
|
|
1898
|
+
if (typeof mod.performLocalRelease !== 'function') return null;
|
|
1899
|
+
return mod;
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/**
|
|
1906
|
+
* Resolve the effective assignment-provider kind for this repo ('local-file' | 'github'),
|
|
1907
|
+
* or null when it cannot be determined. Prefers reading context/settings/
|
|
1908
|
+
* assignment-provider-settings.json directly (project settings first, matching
|
|
1909
|
+
* effective-assignment-provider-settings.ts's own lookup precedence) rather than shelling
|
|
1910
|
+
* out to or refactoring that CLI-shaped file (see plan Unresolved Question 1) — this task's
|
|
1911
|
+
* scope is the Stop hook, not that file. Returns null (never guesses) when no settings file
|
|
1912
|
+
* is present/parseable or names no provider kind for a project entry.
|
|
1913
|
+
*
|
|
1914
|
+
* Deliberately reads ONLY the project settings file (context/settings/
|
|
1915
|
+
* assignment-provider-settings.json relative to repo root) — the common case for this repo
|
|
1916
|
+
* and the one confirmed present. The effective() merge/repo-matching logic in
|
|
1917
|
+
* effective-assignment-provider-settings.ts is intentionally NOT reimplemented here (that
|
|
1918
|
+
* would fork a second merge algorithm); a repo whose provider kind can only be resolved via
|
|
1919
|
+
* global settings or multi-project merge degrades to "unknown, skip assignment-layer
|
|
1920
|
+
* release" rather than guessing.
|
|
1921
|
+
*/
|
|
1922
|
+
function resolveAssignmentProviderKind(root) {
|
|
1923
|
+
const settingsFile = path.join(root, 'context', 'settings', 'assignment-provider-settings.json');
|
|
1924
|
+
const settings = readJsonFile(settingsFile);
|
|
1925
|
+
if (!settings) return null;
|
|
1926
|
+
const candidates = [];
|
|
1927
|
+
if (settings.defaults && settings.defaults.provider) candidates.push(settings.defaults.provider);
|
|
1928
|
+
if (Array.isArray(settings.projects)) {
|
|
1929
|
+
for (const project of settings.projects) {
|
|
1930
|
+
if (project && project.provider) candidates.push(project.provider);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
const kind = candidates.map(p => p && p.kind).find(k => k === 'local-file' || k === 'github');
|
|
1934
|
+
return kind || null;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Refresh handoff.json by merge (read+spread the existing file's JSON, overwrite only the
|
|
1939
|
+
* fields this task owns), mirroring advanceState's own handoff-write shape
|
|
1940
|
+
* (`{ ...loadJson(...), ...sidecarBase(slug), summary, current_state_ref: "state.json",
|
|
1941
|
+
* next_steps, blockers: [], warnings: [] }`) and current-pointer.js's writePerActorCurrent
|
|
1942
|
+
* write idiom (`fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n")`). next_steps
|
|
1943
|
+
* is appended to (not clobbered) when a provider-release next-step is due.
|
|
1944
|
+
*/
|
|
1945
|
+
function refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand) {
|
|
1946
|
+
const file = path.join(artifactDir, 'handoff.json');
|
|
1947
|
+
const existing = readJsonFile(file) || {};
|
|
1948
|
+
const status = state ? normalizedStatus(state.status || 'unknown') : 'unknown';
|
|
1949
|
+
const phase = state ? normalizedStatus(state.phase || 'unknown') : 'unknown';
|
|
1950
|
+
const summary = (state && state.next_action && state.next_action.summary)
|
|
1951
|
+
? String(state.next_action.summary)
|
|
1952
|
+
: `session ended at status:${status} phase:${phase}`;
|
|
1953
|
+
const existingNextSteps = Array.isArray(existing.next_steps) ? existing.next_steps.slice() : [];
|
|
1954
|
+
const nextSteps = existingNextSteps.slice();
|
|
1955
|
+
if (providerReleasePending && providerReleaseNextCommand && !nextSteps.includes(providerReleaseNextCommand)) {
|
|
1956
|
+
nextSteps.push(providerReleaseNextCommand);
|
|
1957
|
+
}
|
|
1958
|
+
const payload = {
|
|
1959
|
+
...existing,
|
|
1960
|
+
schema_version: existing.schema_version || '1.0',
|
|
1961
|
+
task_slug: existing.task_slug || slug,
|
|
1962
|
+
summary,
|
|
1963
|
+
current_state_ref: 'state.json',
|
|
1964
|
+
next_steps: nextSteps,
|
|
1965
|
+
...(providerReleasePending ? { provider_release_pending: true } : {}),
|
|
1966
|
+
...(providerReleaseNextCommand ? { provider_release_next_command: providerReleaseNextCommand } : {}),
|
|
1967
|
+
};
|
|
1968
|
+
fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* #292 Wave 2: on a Stop event whose resolved session is at a NON-terminal status, release
|
|
1973
|
+
* the session's liveness claim (always, provider-agnostic — AC1) and, for the local-file
|
|
1974
|
+
* assignment provider, its durable assignment claim too (AC2), then refresh handoff.json
|
|
1975
|
+
* (AC4). A terminal-status session (already released by advance-state) is a deliberate no-op
|
|
1976
|
+
* (AC5). For the github provider, never calls render-release or gh — only records an
|
|
1977
|
+
* honestly-labeled pending intent in handoff.json (AC3). Actor-scoped: only ever releases
|
|
1978
|
+
* the CURRENT actor's own held claim (AC6, enforced inside performLocalRelease).
|
|
1979
|
+
*
|
|
1980
|
+
* Called once from run(), reusing analyze()'s already-resolved latestArtifactDir — this
|
|
1981
|
+
* function does not re-derive "what is the active session" a second time.
|
|
1982
|
+
*
|
|
1983
|
+
* Fail-open (AC7): the entire body is wrapped in one try/catch. Any failure degrades to a
|
|
1984
|
+
* single stderr diagnostic and returns, never throwing, never affecting the Stop hook's exit
|
|
1985
|
+
* code or goal-fit's warnings/blocking contract (this function's return value is unused by
|
|
1986
|
+
* that contract).
|
|
1987
|
+
*
|
|
1988
|
+
* @param {string} root - repo root (as resolved by findRepoRoot)
|
|
1989
|
+
* @param {string|null} artifactDir - analyze()'s already-resolved latestArtifactDir
|
|
1990
|
+
*/
|
|
1991
|
+
function releaseOnNonTerminalStop(root, artifactDir) {
|
|
1992
|
+
try {
|
|
1993
|
+
if (!artifactDir) return; // no active session resolved — nothing to release.
|
|
1994
|
+
|
|
1995
|
+
const state = readJsonFile(path.join(artifactDir, 'state.json'));
|
|
1996
|
+
if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
|
|
1997
|
+
|
|
1998
|
+
const sidecar = loadWorkflowSidecarBuilt();
|
|
1999
|
+
if (!sidecar) {
|
|
2000
|
+
process.stderr.write('[Hook] Goal Fit: stop-hook release skipped — build/src/cli/workflow-sidecar.js not available (LIVENESS_TERMINAL boundary unknown).\n');
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const status = normalizedStatus(state.status || 'unknown');
|
|
2005
|
+
if (sidecar.LIVENESS_TERMINAL.has(status)) return; // AC5: terminal — advance-state already released.
|
|
2006
|
+
|
|
2007
|
+
// Slug: artifact dir's basename, or state.json's own task_slug field — reuse whichever
|
|
2008
|
+
// this file already has, never a new slug-derivation rule.
|
|
2009
|
+
const slug = (state.task_slug && String(state.task_slug).trim()) || path.basename(artifactDir);
|
|
2010
|
+
|
|
2011
|
+
const { actor } = resolveActor(process.env);
|
|
2012
|
+
if (isUnresolvedActor(actor)) {
|
|
2013
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for "${safeOneLine(slug, 80)}" — actor identity is unresolved; never releasing under a synthetic identity.\n`);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const flowAgentsDir = path.dirname(artifactDir);
|
|
2018
|
+
const nowIso = new Date().toISOString();
|
|
2019
|
+
|
|
2020
|
+
// AC1: ALWAYS emit the liveness release, regardless of provider kind — liveness is
|
|
2021
|
+
// provider-agnostic and this is the ONE shared writer every other liveness emitter uses.
|
|
2022
|
+
try {
|
|
2023
|
+
require('./lib/liveness-write.js').appendLivenessEvent(flowAgentsDir, {
|
|
2024
|
+
type: 'release',
|
|
2025
|
+
subjectId: slug,
|
|
2026
|
+
actor,
|
|
2027
|
+
at: nowIso,
|
|
2028
|
+
source: 'stop-hook',
|
|
2029
|
+
});
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook liveness release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Resolve the effective assignment-provider kind — unknown means "do not guess",
|
|
2035
|
+
// skip the assignment-layer release entirely (fail-open).
|
|
2036
|
+
const providerKind = resolveAssignmentProviderKind(root);
|
|
2037
|
+
if (!providerKind) {
|
|
2038
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release — provider kind unknown for "${safeOneLine(slug, 80)}", skipping assignment-layer release (liveness release already emitted).\n`);
|
|
2039
|
+
refreshHandoffAfterRelease(artifactDir, slug, state, false, null);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
let providerReleasePending = false;
|
|
2044
|
+
let providerReleaseNextCommand = null;
|
|
2045
|
+
|
|
2046
|
+
if (providerKind === 'local-file') {
|
|
2047
|
+
const provider = loadAssignmentProviderBuilt();
|
|
2048
|
+
if (!provider) {
|
|
2049
|
+
process.stderr.write('[Hook] Goal Fit: stop-hook local-file release skipped — build/src/cli/assignment-provider.js not available.\n');
|
|
2050
|
+
} else {
|
|
2051
|
+
try {
|
|
2052
|
+
// Mirror assignment-provider.ts's loadActorStruct() exactly: runtime is derived via
|
|
2053
|
+
// detectRuntime (not a literal 'unknown'), since serializeActor()'s comparison key
|
|
2054
|
+
// includes runtime — a mismatched runtime segment here would make performLocalRelease's
|
|
2055
|
+
// AC6 actor-match check wrongly treat this session's own claim as foreign.
|
|
2056
|
+
//
|
|
2057
|
+
// actorKey: opts.actorKey MUST be the same canonical `resolveActor(env).actor` string
|
|
2058
|
+
// already resolved above (`actor`) — the identical bare-or-triple form
|
|
2059
|
+
// computeEffectiveState()'s holderActorKey preference (`record.actor_key ||
|
|
2060
|
+
// serializeActor(record.actor)`) and performLocalRelease's mirrored ownership check
|
|
2061
|
+
// both key on. Without this, an explicit-override actor's release-side comparison
|
|
2062
|
+
// would fall back to serializeActor(releasedBy) — a re-derived triple that never
|
|
2063
|
+
// equals the stored bare actor_key — and this hook would wrongly treat its own claim
|
|
2064
|
+
// as foreign (the #291 seam, relocated to the release path; see performLocalRelease's
|
|
2065
|
+
// doc comment in src/cli/assignment-provider.ts).
|
|
2066
|
+
const releasedBy = { runtime: detectRuntime(process.env), session_id: actor, host: os.hostname(), human: null };
|
|
2067
|
+
const artifactRoot = flowAgentsArtifactRoot(root);
|
|
2068
|
+
provider.performLocalRelease(artifactRoot, slug, releasedBy, {
|
|
2069
|
+
reason: 'stop-hook non-terminal release',
|
|
2070
|
+
tolerateNoActiveClaim: true,
|
|
2071
|
+
actorKey: actor,
|
|
2072
|
+
});
|
|
2073
|
+
} catch (err) {
|
|
2074
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook local-file release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
} else {
|
|
2078
|
+
// AC3: github (or any non-local-file kind) — render-don't-execute. Never call
|
|
2079
|
+
// render-release, never invoke gh. Disclose the pending intent honestly.
|
|
2080
|
+
providerReleasePending = true;
|
|
2081
|
+
providerReleaseNextCommand = `npm run workflow -- assignment-provider status --provider github --subject-id "${slug}" ... # then: assignment-provider render-release --provider github --subject-id "${slug}" ... (emits the gh argv to run)`;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// AC4: refresh handoff.json — merge, never fully overwrite.
|
|
2085
|
+
refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand);
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
// Fail-open (AC7): any unexpected failure anywhere above (JSON parse error, fs error,
|
|
2088
|
+
// etc.) must never crash the Stop hook or affect its exit code / goal-fit's output.
|
|
2089
|
+
try {
|
|
2090
|
+
process.stderr.write(`[Hook] Goal Fit: stop-hook release logic error (fail-open): ${safeOneLine(err && err.message || err, 160)}\n`);
|
|
2091
|
+
} catch { /* best-effort diagnostic only */ }
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1845
2095
|
async function run(rawInput) {
|
|
1846
2096
|
const input = parseJson(rawInput);
|
|
1847
2097
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
1848
2098
|
const mode = resolveGoalFitMode();
|
|
1849
2099
|
if (mode === 'off') return rawInput;
|
|
1850
2100
|
const result = await analyze(root);
|
|
2101
|
+
// #292 Wave 2: additive side effect only — never changes analyze()'s warnings/blocking
|
|
2102
|
+
// contract or this function's return value. Reuses analyze()'s already-resolved
|
|
2103
|
+
// latestArtifactDir rather than re-deriving a second "what is the active session" path.
|
|
2104
|
+
// Runs regardless of whether goal-fit itself has any warnings this turn (AC1: the
|
|
2105
|
+
// liveness release must always be attempted on a non-terminal Stop, independent of
|
|
2106
|
+
// whether goal-fit found anything to warn about).
|
|
2107
|
+
releaseOnNonTerminalStop(root, result.latestArtifactDir || null);
|
|
1851
2108
|
if (result.warnings.length === 0) {
|
|
1852
2109
|
clearBlockStreak(root);
|
|
1853
2110
|
return rawInput;
|
|
@@ -1937,4 +2194,4 @@ if (require.main === module) {
|
|
|
1937
2194
|
});
|
|
1938
2195
|
}
|
|
1939
2196
|
|
|
1940
|
-
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator };
|
|
2197
|
+
module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator, releaseOnNonTerminalStop };
|
|
@@ -19,6 +19,7 @@ const path = require('path');
|
|
|
19
19
|
const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
|
|
20
20
|
const { resolveActor } = require('./lib/actor-identity');
|
|
21
21
|
const { flowAgentsArtifactRootsForRead } = require('./lib/local-artifact-paths');
|
|
22
|
+
const { readCurrentPointer } = require('./lib/current-pointer');
|
|
22
23
|
|
|
23
24
|
const STEERING = {
|
|
24
25
|
'tool-planner': [
|
|
@@ -102,7 +103,48 @@ function readJson(file) {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* #291 (Conflict #2, Wave 2 Task 2.4): actor-scoped preference consulted BEFORE the global
|
|
108
|
+
* newest-mtime scan below. For each read-root, resolve the "current" pointer via
|
|
109
|
+
* `readCurrentPointer` (per-actor `current/<actor>.json` preferred, legacy global
|
|
110
|
+
* `current.json` fallback — the same single choke point every other Wave 2 consumer uses) and,
|
|
111
|
+
* if it names a session dir whose `state.json` is still in an ACTIVE_STATE_STATUSES status,
|
|
112
|
+
* return that state immediately — so actor A's own ambient steering hint always prefers A's own
|
|
113
|
+
* current task over a globally-newer-mtime `state.json` some other actor (B) happened to touch
|
|
114
|
+
* more recently. Returns null (never throws) when no root yields an actor-scoped resolution,
|
|
115
|
+
* so the caller falls through to the unchanged global scan below — this is the compat-shim
|
|
116
|
+
* guarantee: when `actorKey` is empty/unresolved and/or no per-actor file exists yet, this
|
|
117
|
+
* degrades to the SAME legacy-`current.json`-or-nothing lookup the compat shim already performs
|
|
118
|
+
* for every other Wave 2 reader, so a single-session/CI/legacy-only fixture cannot fail to
|
|
119
|
+
* resolve here in a way it wouldn't already have resolved via the global scan.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} root
|
|
122
|
+
* @param {string} actorKey
|
|
123
|
+
* @returns {{file: string, payload: object, mtimeMs: number}|null}
|
|
124
|
+
*/
|
|
125
|
+
function actorScopedWorkflowState(root, actorKey) {
|
|
126
|
+
for (const flowAgentsDir of flowAgentsArtifactRootsForRead(root)) {
|
|
127
|
+
const { payload: current } = readCurrentPointer(flowAgentsDir, actorKey);
|
|
128
|
+
if (!current) continue;
|
|
129
|
+
const slug = current.artifact_dir || current.active_slug;
|
|
130
|
+
if (typeof slug !== 'string' || !slug.trim()) continue;
|
|
131
|
+
const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
|
|
132
|
+
const dir = path.join(flowAgentsDir, safe);
|
|
133
|
+
if (!dir.startsWith(flowAgentsDir + path.sep) || !fs.existsSync(dir)) continue;
|
|
134
|
+
const file = path.join(dir, 'state.json');
|
|
135
|
+
let stat;
|
|
136
|
+
try { stat = fs.statSync(file); } catch { continue; }
|
|
137
|
+
const payload = readJson(file);
|
|
138
|
+
if (!payload || !ACTIVE_STATE_STATUSES.has(payload.status)) continue;
|
|
139
|
+
return { file, payload, mtimeMs: stat.mtimeMs };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
105
144
|
function latestWorkflowState(root) {
|
|
145
|
+
const preferred = actorScopedWorkflowState(root, resolveActor(process.env).actor);
|
|
146
|
+
if (preferred) return preferred;
|
|
147
|
+
|
|
106
148
|
const states = flowAgentsArtifactRootsForRead(root)
|
|
107
149
|
.flatMap(artifactRoot => walkStateFiles(artifactRoot))
|
|
108
150
|
.map(file => {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const { resolveActor } = require('../hooks/lib/actor-identity.js');
|
|
14
|
+
const { readCurrentPointer } = require('../hooks/lib/current-pointer.js');
|
|
13
15
|
|
|
14
16
|
const MAX_SUMMARY = 72;
|
|
15
17
|
|
|
@@ -76,7 +78,7 @@ function stateFiles(workflowRoot) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
function chooseWorkflow(workflowRoot) {
|
|
79
|
-
const current =
|
|
81
|
+
const { payload: current } = readCurrentPointer(workflowRoot, resolveActor(process.env).actor);
|
|
80
82
|
if (current?.active_slug || current?.artifact_dir) {
|
|
81
83
|
const slug = current.active_slug || current.artifact_dir;
|
|
82
84
|
const dir = path.join(workflowRoot, slug);
|