@kontourai/flow-agents 3.1.0 → 3.3.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 +16 -0
- package/CHANGELOG.md +30 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +107 -13
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +238 -4
- package/build/src/cli/workflow-sidecar.js +875 -14
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/context/scripts/hooks/workflow-steering.js +73 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/index.md +2 -2
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/fixture-ownership.md +3 -2
- package/docs/index.md +4 -0
- package/docs/integrations/flow-agents-console.md +108 -0
- package/docs/integrations/index.md +4 -0
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +8 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/integration/test_checkpoint_signing.sh +14 -5
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +73 -8
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +125 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -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/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +22 -2
- 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 +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- 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 +36 -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/veritas-governance/docs/README.md +81 -3
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +5 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +222 -279
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +122 -13
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +965 -14
- package/src/tools/validate-source-tree.ts +3 -2
|
@@ -229,6 +229,75 @@ function sanitizeSegment(value) {
|
|
|
229
229
|
return cleaned || 'unknown';
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/**
|
|
233
|
+
* #398: detect a CI-triggered session and derive a STABLE actor identity from the CI provider's
|
|
234
|
+
* published identifiers. The right granularity is the JOB/RUN (a CI job ≈ one agent session), NOT
|
|
235
|
+
* the runner machine (hostname can be shared/reused across jobs). Detection is order-independent
|
|
236
|
+
* (each provider gates on its own canonical marker env var). Returns `{ runtime, session_id }` on a
|
|
237
|
+
* recognized provider WITH a non-blank stable id, else `null` (caller falls through to
|
|
238
|
+
* process-ancestry, where #293's advisory verify-hold net still protects).
|
|
239
|
+
*
|
|
240
|
+
* Deliberately conservative: a generic/unrecognized `CI=true` returns null rather than fabricating
|
|
241
|
+
* a "stable" id from something that might shift between subprocesses — a wrong stable classification
|
|
242
|
+
* would let the #293 hard gate ENFORCE against a shifting identity, which is worse than advisory.
|
|
243
|
+
* All segments are sanitized by serializeActor at the call site (allowed charset, length-capped),
|
|
244
|
+
* so a hostile env var value cannot inject.
|
|
245
|
+
*
|
|
246
|
+
* ACCEPTED GRANULARITY GAP (matrix / parallelism): the id is job/run-granular. GitLab (`CI_JOB_ID`),
|
|
247
|
+
* Azure (`SYSTEM_JOBID`), and Buildkite (`BUILDKITE_JOB_ID`) expose a per-job-INSTANCE id that is
|
|
248
|
+
* already unique across matrix/parallel legs. CircleCI parallelism is disambiguated below via
|
|
249
|
+
* `CIRCLE_NODE_INDEX`. But GitHub Actions and Jenkins declarative-`matrix{}` cells share
|
|
250
|
+
* `GITHUB_JOB` / `BUILD_TAG` across all legs of one run+attempt — matrix values live only in the
|
|
251
|
+
* `${{ matrix }}` / axis context, not in any env var this can read — so two concurrent legs collapse
|
|
252
|
+
* to the SAME CI actor. This degrades SAFELY: worst case is idempotent self-recognition (one leg
|
|
253
|
+
* treats another's claim as its own); it never false-blocks and never injects. The coordination-
|
|
254
|
+
* relevant CI jobs (trust-reconcile / publish) run as single, non-matrix jobs today. Filed as a
|
|
255
|
+
* fast-follow to add a GitHub-matrix disambiguator if a coordination path ever runs under a matrix.
|
|
256
|
+
*
|
|
257
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
258
|
+
* @returns {{runtime: string, session_id: string} | null}
|
|
259
|
+
*/
|
|
260
|
+
function detectCiActor(env = process.env) {
|
|
261
|
+
env = env || {};
|
|
262
|
+
const s = (v) => String(v == null ? '' : v).trim();
|
|
263
|
+
const compose = (...parts) => parts.map(s).filter(Boolean).join('-');
|
|
264
|
+
|
|
265
|
+
// GitHub Actions — run id + attempt + job is unique and stable across the whole job.
|
|
266
|
+
if (s(env.GITHUB_ACTIONS) === 'true') {
|
|
267
|
+
const id = compose(env.GITHUB_RUN_ID, env.GITHUB_RUN_ATTEMPT, env.GITHUB_JOB);
|
|
268
|
+
return id ? { runtime: 'github-actions', session_id: id } : null;
|
|
269
|
+
}
|
|
270
|
+
// GitLab CI — CI_JOB_ID is stable per job.
|
|
271
|
+
if (s(env.GITLAB_CI) === 'true') {
|
|
272
|
+
const id = s(env.CI_JOB_ID);
|
|
273
|
+
return id ? { runtime: 'gitlab-ci', session_id: id } : null;
|
|
274
|
+
}
|
|
275
|
+
// CircleCI — workflow id + job name + node index (CIRCLE_NODE_INDEX disambiguates the containers
|
|
276
|
+
// of a `parallelism: N` job; "0" for a single-container job, so it is always present and stable).
|
|
277
|
+
if (s(env.CIRCLECI) === 'true') {
|
|
278
|
+
const id = compose(env.CIRCLE_WORKFLOW_ID, env.CIRCLE_JOB, env.CIRCLE_NODE_INDEX) || s(env.CIRCLE_BUILD_NUM);
|
|
279
|
+
return id ? { runtime: 'circleci', session_id: id } : null;
|
|
280
|
+
}
|
|
281
|
+
// Jenkins — BUILD_TAG is the stable per-build identifier; fall back to job + build id.
|
|
282
|
+
if (s(env.JENKINS_URL)) {
|
|
283
|
+
const id = s(env.BUILD_TAG) || compose(env.JOB_NAME, env.BUILD_ID);
|
|
284
|
+
return id ? { runtime: 'jenkins', session_id: id } : null;
|
|
285
|
+
}
|
|
286
|
+
// Azure Pipelines — build id + system job id (SYSTEM_JOBID is a per-job-instance GUID, unique
|
|
287
|
+
// across matrix/strategy legs).
|
|
288
|
+
if (s(env.TF_BUILD) === 'true') {
|
|
289
|
+
const id = compose(env.BUILD_BUILDID, env.SYSTEM_JOBID);
|
|
290
|
+
return id ? { runtime: 'azure-pipelines', session_id: id } : null;
|
|
291
|
+
}
|
|
292
|
+
// Buildkite — job id is a stable UUID per job.
|
|
293
|
+
if (s(env.BUILDKITE) === 'true') {
|
|
294
|
+
const id = s(env.BUILDKITE_JOB_ID);
|
|
295
|
+
return id ? { runtime: 'buildkite', session_id: id } : null;
|
|
296
|
+
}
|
|
297
|
+
// Generic/unrecognized CI: do NOT fabricate stability — fall through to process-ancestry.
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
232
301
|
/**
|
|
233
302
|
* Serialize a runtime-agnostic actor struct into a single string safe for
|
|
234
303
|
* the existing `${subjectId}::${actor}` grouping key: each field is passed
|
|
@@ -334,6 +403,18 @@ function resolveActor(env = process.env) {
|
|
|
334
403
|
return { actor, source: `runtime-session-id:${runtime}` };
|
|
335
404
|
}
|
|
336
405
|
|
|
406
|
+
// #398: CI-runtime tier — sits ABOVE process-ancestry (stable across every subprocess in a CI
|
|
407
|
+
// job) and BELOW an explicit override / native runtime session id (those are more specific and
|
|
408
|
+
// already returned above). A CI-triggered agent session otherwise falls to process-ancestry,
|
|
409
|
+
// whose seed differs across subprocesses within one job (a subject claimed in `ensure-session`
|
|
410
|
+
// isn't recognized as self at `publish`) — the exact instability #293 had to degrade the
|
|
411
|
+
// verify-hold gate to advisory for. A stable CI identity lets that gate ENFORCE instead.
|
|
412
|
+
const ci = detectCiActor(env);
|
|
413
|
+
if (ci && ci.session_id) {
|
|
414
|
+
const actor = serializeActor({ runtime: ci.runtime, session_id: ci.session_id, host: os.hostname() });
|
|
415
|
+
return { actor, source: `ci-runtime:${ci.runtime}` };
|
|
416
|
+
}
|
|
417
|
+
|
|
337
418
|
const seed = ancestorActorSeed();
|
|
338
419
|
if (seed) {
|
|
339
420
|
const actor = serializeActor({ runtime, session_id: `anc-${seed}`, host: os.hostname() });
|
|
@@ -360,6 +441,7 @@ function isUnresolvedActor(actor) {
|
|
|
360
441
|
module.exports = {
|
|
361
442
|
detectRuntime,
|
|
362
443
|
runtimeSessionId,
|
|
444
|
+
detectCiActor,
|
|
363
445
|
ancestorActorSeed,
|
|
364
446
|
sanitizeSegment,
|
|
365
447
|
serializeActor,
|
|
@@ -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,7 +43,7 @@ const {
|
|
|
42
43
|
flowAgentsArtifactRoot,
|
|
43
44
|
flowAgentsArtifactRootsForRead,
|
|
44
45
|
} = require('./lib/local-artifact-paths');
|
|
45
|
-
const { resolveActor } = require('./lib/actor-identity.js');
|
|
46
|
+
const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
|
|
46
47
|
const { readCurrentPointer } = require('./lib/current-pointer.js');
|
|
47
48
|
|
|
48
49
|
const MAX_STDIN = 1024 * 1024;
|
|
@@ -1671,7 +1672,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1671
1672
|
artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
|
|
1672
1673
|
}
|
|
1673
1674
|
|
|
1674
|
-
if (artifacts.length === 0) return { warnings: [], blocking: false };
|
|
1675
|
+
if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
|
|
1675
1676
|
|
|
1676
1677
|
const latest = artifacts[0];
|
|
1677
1678
|
const latestArtifactDir = path.dirname(latest.file);
|
|
@@ -1745,7 +1746,7 @@ async function analyze(root, now = Date.now()) {
|
|
|
1745
1746
|
if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
|
|
1746
1747
|
return blockRe.test(w);
|
|
1747
1748
|
});
|
|
1748
|
-
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
|
|
1749
|
+
return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
|
|
1749
1750
|
}
|
|
1750
1751
|
|
|
1751
1752
|
/**
|
|
@@ -1844,12 +1845,266 @@ function remediationFor(warning) {
|
|
|
1844
1845
|
return null;
|
|
1845
1846
|
}
|
|
1846
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
|
+
|
|
1847
2095
|
async function run(rawInput) {
|
|
1848
2096
|
const input = parseJson(rawInput);
|
|
1849
2097
|
const root = findRepoRoot(input.cwd || process.cwd());
|
|
1850
2098
|
const mode = resolveGoalFitMode();
|
|
1851
2099
|
if (mode === 'off') return rawInput;
|
|
1852
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);
|
|
1853
2108
|
if (result.warnings.length === 0) {
|
|
1854
2109
|
clearBlockStreak(root);
|
|
1855
2110
|
return rawInput;
|
|
@@ -1939,4 +2194,4 @@ if (require.main === module) {
|
|
|
1939
2194
|
});
|
|
1940
2195
|
}
|
|
1941
2196
|
|
|
1942
|
-
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 };
|
|
@@ -159,8 +159,25 @@ function latestWorkflowState(root) {
|
|
|
159
159
|
return states[0] || null;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// #293 AC7 (injection discipline): strips C0 (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F,
|
|
163
|
+
// which includes ANSI-CSI-adjacent bytes) BEFORE whitespace-collapse/truncation — mirroring
|
|
164
|
+
// src/cli/workflow-sidecar.ts's stripControlCharsForDisplay / assignment-provider.ts's
|
|
165
|
+
// sanitizeDisplayField exactly (same charset, same rationale: holder/actor/assignee strings
|
|
166
|
+
// sourced from the shared multi-writer liveness stream or an attacker-postable GitHub comment
|
|
167
|
+
// are untrusted display input). Previously this function only collapsed whitespace, so a raw
|
|
168
|
+
// ESC/BEL byte embedded in a hostile actor string passed through unstripped into every steering
|
|
169
|
+
// notice that calls safeStateText (resumeSteering's LIVENESS WARNING block, supersessionSteering's
|
|
170
|
+
// SUPERSEDED notice) — fixed here, in the one shared helper, rather than in each call site.
|
|
162
171
|
function safeStateText(value, maxLength = 240) {
|
|
163
|
-
|
|
172
|
+
// Whitespace-collapse FIRST (so an embedded newline/tab becomes a joining space, preserving
|
|
173
|
+
// the existing multiline-summary neutralization behavior), THEN strip control/ANSI bytes
|
|
174
|
+
// (#293 AC7) — reversing this order would strip \n before the collapse ever sees it, losing
|
|
175
|
+
// the join-space between sentences. Only C0/DEL/C1 bytes THAT SURVIVE the collapse (ESC, BEL,
|
|
176
|
+
// and other non-whitespace control bytes — real \s already became a plain space above) are
|
|
177
|
+
// stripped, mirroring src/cli/workflow-sidecar.ts's stripControlCharsForDisplay /
|
|
178
|
+
// assignment-provider.ts's sanitizeDisplayField charset exactly.
|
|
179
|
+
const collapsed = String(value || '').replace(/\s+/g, ' ').trim();
|
|
180
|
+
const text = collapsed.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
164
181
|
if (text.length <= maxLength) return text;
|
|
165
182
|
return `${text.slice(0, maxLength - 3)}...`;
|
|
166
183
|
}
|
|
@@ -411,6 +428,51 @@ function resumeSteering(root, current) {
|
|
|
411
428
|
}
|
|
412
429
|
}
|
|
413
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Compose the every-turn SUPERSEDED steering notice (issue #293).
|
|
433
|
+
*
|
|
434
|
+
* Unlike resumeSteering()'s RESUME block (SessionStart only), this notice must
|
|
435
|
+
* surface on EVERY turn (UserPromptSubmit as well as SessionStart) once another
|
|
436
|
+
* actor holds a fresh liveness claim on the caller's own subject — the
|
|
437
|
+
* "don't publish, you may be stale" signal the verify-hold gate backstops at
|
|
438
|
+
* publish time. Reuses the SAME liveness-only join resumeSteering()'s liveness
|
|
439
|
+
* advisory block already performs (freshHolders(events, slug, selfActor, now)),
|
|
440
|
+
* not a second computation.
|
|
441
|
+
*
|
|
442
|
+
* freshHolders() already excludes selfActor internally (see
|
|
443
|
+
* scripts/hooks/lib/liveness-read.js), so any holder returned here is by
|
|
444
|
+
* definition another actor — no additional self-filter is required.
|
|
445
|
+
*
|
|
446
|
+
* Fully fail-open: any error anywhere in this function returns '' rather than
|
|
447
|
+
* throwing, matching every other steering helper in this file.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} root Repository root
|
|
450
|
+
* @param {{ file: string, payload: object }} current Latest active state entry (this actor's own)
|
|
451
|
+
* @returns {string}
|
|
452
|
+
*/
|
|
453
|
+
function supersessionSteering(root, current) {
|
|
454
|
+
try {
|
|
455
|
+
const state = current.payload;
|
|
456
|
+
const workflowDir = path.dirname(current.file);
|
|
457
|
+
const slug = state.task_slug || path.basename(workflowDir);
|
|
458
|
+
|
|
459
|
+
const resolved = resolveActor(process.env);
|
|
460
|
+
const selfActor = resolved.actor || 'local';
|
|
461
|
+
|
|
462
|
+
const events = flowAgentsArtifactRootsForRead(root)
|
|
463
|
+
.flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
|
|
464
|
+
if (events.length === 0) return '';
|
|
465
|
+
|
|
466
|
+
const holders = freshHolders(events, slug, selfActor, Date.now());
|
|
467
|
+
if (holders.length === 0) return '';
|
|
468
|
+
|
|
469
|
+
const holder = holders[0];
|
|
470
|
+
return `[SUPERSEDED: another agent appears live on this work: actor ${safeStateText(holder.actor)}, last seen ${holder.lastAt}. Your claim on this subject may be stale — do not publish until you re-verify hold (see verify-hold gate) or hand off.]`;
|
|
471
|
+
} catch {
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
414
476
|
function run(rawInput) {
|
|
415
477
|
try {
|
|
416
478
|
const input = JSON.parse(rawInput);
|
|
@@ -451,10 +513,19 @@ function run(rawInput) {
|
|
|
451
513
|
}
|
|
452
514
|
}
|
|
453
515
|
|
|
516
|
+
// Every-turn supersession notice (#293): fires on UserPromptSubmit (every turn),
|
|
517
|
+
// not just SessionStart, so a takeover mid-session keeps surfacing the warning.
|
|
518
|
+
if (event === 'UserPromptSubmit' && current) {
|
|
519
|
+
const supersessionHint = supersessionSteering(root, current);
|
|
520
|
+
if (supersessionHint) hints.push(supersessionHint);
|
|
521
|
+
}
|
|
522
|
+
|
|
454
523
|
// SessionStart only: append the RESUME block for richer situational awareness
|
|
455
524
|
if (event === 'SessionStart' && current) {
|
|
456
525
|
const resumeBlock = resumeSteering(root, current);
|
|
457
526
|
if (resumeBlock) hints.push(resumeBlock);
|
|
527
|
+
const supersessionHint = supersessionSteering(root, current);
|
|
528
|
+
if (supersessionHint) hints.push(supersessionHint);
|
|
458
529
|
}
|
|
459
530
|
|
|
460
531
|
if (shouldAppendWorkflowContext) {
|
|
@@ -492,6 +563,7 @@ module.exports = {
|
|
|
492
563
|
safeStateText,
|
|
493
564
|
stateNeedsAmbientSteering,
|
|
494
565
|
resumeSteering,
|
|
566
|
+
supersessionSteering,
|
|
495
567
|
promptText,
|
|
496
568
|
looksLikeBuilderWork,
|
|
497
569
|
builderWorkflowSteering,
|