@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.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. 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
- const text = String(value || '').replace(/\s+/g, ' ').trim();
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,