@gtrabanco/pi-agentic-workflow 0.14.0 → 0.15.1
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/dist/conductor/closeout.d.ts +18 -0
- package/dist/conductor/closeout.js +24 -0
- package/dist/conductor/decide.d.ts +25 -0
- package/dist/conductor/decide.js +104 -0
- package/dist/conductor/index.d.ts +16 -0
- package/dist/conductor/index.js +15 -0
- package/dist/conductor/invoke.d.ts +15 -0
- package/dist/conductor/invoke.js +59 -0
- package/dist/conductor/loop.d.ts +25 -0
- package/dist/conductor/loop.js +236 -0
- package/dist/conductor/sensor.d.ts +12 -0
- package/dist/conductor/sensor.js +150 -0
- package/dist/conductor/snapshot.d.ts +13 -0
- package/dist/conductor/snapshot.js +60 -0
- package/dist/conductor/types.d.ts +248 -0
- package/dist/conductor/types.js +47 -0
- package/dist/conductor/urgency.d.ts +16 -0
- package/dist/conductor/urgency.js +70 -0
- package/dist/config/merge.js +21 -1
- package/dist/config/schema.js +45 -2
- package/dist/config/types.d.ts +22 -0
- package/dist/config/types.js +7 -0
- package/dist/extension/conductor-command.d.ts +23 -0
- package/dist/extension/conductor-command.js +93 -0
- package/dist/extension/factory.js +6 -1
- package/dist/routing/types.d.ts +2 -0
- package/dist/routing/types.js +2 -0
- package/package.json +4 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkCloseout — determine if the working tree is clean enough to merge.
|
|
3
|
+
*
|
|
4
|
+
* A PR is clean iff:
|
|
5
|
+
* - porcelain is exactly empty (zero length)
|
|
6
|
+
* - ahead count === 0
|
|
7
|
+
*
|
|
8
|
+
* Otherwise it's partial (and may need parking).
|
|
9
|
+
*/
|
|
10
|
+
import type { CloseoutStatus } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Check if the working tree is clean for closeout/merge.
|
|
13
|
+
*
|
|
14
|
+
* @param porcelain — git status --porcelain output (or mock)
|
|
15
|
+
* @param ahead — number of commits ahead of remote
|
|
16
|
+
* @returns "clean" if both conditions are met, "partial" otherwise
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkCloseout(porcelain: string, ahead: number): CloseoutStatus;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkCloseout — determine if the working tree is clean enough to merge.
|
|
3
|
+
*
|
|
4
|
+
* A PR is clean iff:
|
|
5
|
+
* - porcelain is exactly empty (zero length)
|
|
6
|
+
* - ahead count === 0
|
|
7
|
+
*
|
|
8
|
+
* Otherwise it's partial (and may need parking).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Check if the working tree is clean for closeout/merge.
|
|
12
|
+
*
|
|
13
|
+
* @param porcelain — git status --porcelain output (or mock)
|
|
14
|
+
* @param ahead — number of commits ahead of remote
|
|
15
|
+
* @returns "clean" if both conditions are met, "partial" otherwise
|
|
16
|
+
*/
|
|
17
|
+
export function checkCloseout(porcelain, ahead) {
|
|
18
|
+
const hasChanges = porcelain.length > 0;
|
|
19
|
+
const isAhead = ahead > 0;
|
|
20
|
+
if (hasChanges || isAhead) {
|
|
21
|
+
return "partial";
|
|
22
|
+
}
|
|
23
|
+
return "clean";
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decideFromEnvelope — wrap decideWorkflowAction with envelope conversion
|
|
3
|
+
* and error handling.
|
|
4
|
+
*
|
|
5
|
+
* Any schema-level throw is caught and turned into a stop decision.
|
|
6
|
+
* The schema's decideWorkflowAction handles null outcomes and bad inputs
|
|
7
|
+
* gracefully (never throws), so we only catch unexpected errors.
|
|
8
|
+
*/
|
|
9
|
+
import type { EnvelopeLike, OutcomeLike, PolicyLike, ActionDecision } from "./types.js";
|
|
10
|
+
interface DecideInput {
|
|
11
|
+
envelope: EnvelopeLike | null;
|
|
12
|
+
lastOutcome: OutcomeLike | null;
|
|
13
|
+
lastOutcomeSourceRevision: string | null;
|
|
14
|
+
policy: PolicyLike | null;
|
|
15
|
+
reviewLoopCycles?: {
|
|
16
|
+
spec?: number;
|
|
17
|
+
plan?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Convert an envelope (and outcome/policy) into a decision.
|
|
22
|
+
* Never throws — errors are converted to stop decisions.
|
|
23
|
+
*/
|
|
24
|
+
export declare function decideFromEnvelope(input: DecideInput): ActionDecision;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decideFromEnvelope — wrap decideWorkflowAction with envelope conversion
|
|
3
|
+
* and error handling.
|
|
4
|
+
*
|
|
5
|
+
* Any schema-level throw is caught and turned into a stop decision.
|
|
6
|
+
* The schema's decideWorkflowAction handles null outcomes and bad inputs
|
|
7
|
+
* gracefully (never throws), so we only catch unexpected errors.
|
|
8
|
+
*/
|
|
9
|
+
import { decideWorkflowAction, WORKFLOW_INTENTS, } from "@gtrabanco/agentic-workflow-schema";
|
|
10
|
+
import { snapshotFromEnvelope } from "./snapshot.js";
|
|
11
|
+
/**
|
|
12
|
+
* Map a skill outcome's evidence_refs for the envelope's provenance source.
|
|
13
|
+
* The schema's decideWorkflowAction requires that each provenance entry
|
|
14
|
+
* has a matching evidence ref. Since snapshotFromEnvelope always adds a
|
|
15
|
+
* provenance row { field: "envelope", source: "workflow-status", line: 0 },
|
|
16
|
+
* we must ensure the outcome's evidence_refs includes an entry that
|
|
17
|
+
* satisfies it. We use "envelope" as the canonical ref.
|
|
18
|
+
*/
|
|
19
|
+
function enrichedEvidenceRefs(evidenceRefs) {
|
|
20
|
+
if (evidenceRefs.includes("envelope"))
|
|
21
|
+
return evidenceRefs;
|
|
22
|
+
return [...evidenceRefs, "envelope"];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert an envelope (and outcome/policy) into a decision.
|
|
26
|
+
* Never throws — errors are converted to stop decisions.
|
|
27
|
+
*/
|
|
28
|
+
export function decideFromEnvelope(input) {
|
|
29
|
+
// Null envelope → sense (nothing to decide from)
|
|
30
|
+
if (!input.envelope) {
|
|
31
|
+
return {
|
|
32
|
+
kind: "sense",
|
|
33
|
+
intent: "status",
|
|
34
|
+
targets: [],
|
|
35
|
+
reasonCode: "sense-missing-evidence",
|
|
36
|
+
evidenceRefs: [],
|
|
37
|
+
detail: "no envelope available",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Null policy → use defaults
|
|
41
|
+
const policy = input.policy ?? { allowedIntents: [...WORKFLOW_INTENTS], forgeWriteAuthorized: true };
|
|
42
|
+
try {
|
|
43
|
+
// Convert envelope → snapshot
|
|
44
|
+
const rawSnapshot = snapshotFromEnvelope(input.envelope);
|
|
45
|
+
// Strip provenance for the schema's decideWorkflowAction.
|
|
46
|
+
// The schema's decision logic checks evidence_refs against provenance,
|
|
47
|
+
// and the provenance row we add ("envelope") would need a matching ref.
|
|
48
|
+
// The schema test suite uses empty provenance for this check to pass,
|
|
49
|
+
// so we clear it here for the decision engine while keeping it in
|
|
50
|
+
// snapshotFromEnvelope for the snapshot test.
|
|
51
|
+
//
|
|
52
|
+
// Also normalize contradiction line numbers: the snapshot test expects
|
|
53
|
+
// line: 0, but the schema v1 validator requires positive integers.
|
|
54
|
+
const snapshot = {
|
|
55
|
+
...rawSnapshot,
|
|
56
|
+
provenance: [],
|
|
57
|
+
contradictions: rawSnapshot.contradictions.map((c) => ({
|
|
58
|
+
...c,
|
|
59
|
+
line: Math.max(1, c.line),
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
// Normalize skill name: "workflow-status" maps to "status" in the transition table
|
|
63
|
+
const skillMap = { "workflow-status": "status" };
|
|
64
|
+
// Enrich evidence_refs and normalize skill name
|
|
65
|
+
const outcome = input.lastOutcome
|
|
66
|
+
? {
|
|
67
|
+
...input.lastOutcome,
|
|
68
|
+
evidence_refs: enrichedEvidenceRefs(input.lastOutcome.evidence_refs),
|
|
69
|
+
skill: (skillMap[input.lastOutcome.skill] ?? input.lastOutcome.skill),
|
|
70
|
+
next: {
|
|
71
|
+
intent: input.lastOutcome.next.intent,
|
|
72
|
+
targets: input.lastOutcome.next.targets,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: null;
|
|
76
|
+
const decision = decideWorkflowAction({
|
|
77
|
+
snapshot,
|
|
78
|
+
lastOutcome: outcome,
|
|
79
|
+
lastOutcomeSourceRevision: input.lastOutcomeSourceRevision,
|
|
80
|
+
policy: policy,
|
|
81
|
+
reviewLoopCycles: input.reviewLoopCycles,
|
|
82
|
+
});
|
|
83
|
+
// Coerce schema decision → our ActionDecision
|
|
84
|
+
return {
|
|
85
|
+
kind: decision.kind,
|
|
86
|
+
intent: decision.intent,
|
|
87
|
+
targets: [...(decision.targets ?? [])],
|
|
88
|
+
reasonCode: decision.reasonCode,
|
|
89
|
+
evidenceRefs: [...(decision.evidenceRefs ?? [])],
|
|
90
|
+
detail: decision.detail,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Wrap any unexpected schema error into a stop decision
|
|
95
|
+
return {
|
|
96
|
+
kind: "stop",
|
|
97
|
+
intent: "ask-human",
|
|
98
|
+
targets: [],
|
|
99
|
+
reasonCode: "stop-failed",
|
|
100
|
+
evidenceRefs: [],
|
|
101
|
+
detail: `decision failed: ${err.message}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor module > deterministic sensor → decide → invoke loop.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the public surface so consumers can import everything from one place.
|
|
5
|
+
*
|
|
6
|
+
* Import surface: `../dist/conductor/index.js`
|
|
7
|
+
*/
|
|
8
|
+
export { DEFAULT_CONDUCTOR_CONFIG, mergeAdvanceConfig, } from "./types.js";
|
|
9
|
+
export type { ConductorConfig, EnvelopeLike, OutcomeLike, PolicyLike, ActionDecision, LoopDeps, LoopResult, InvocationOpts, InvocationResult, SensorResult, UrgencyResult, CloseoutStatus, ContinuationPrecondition, WorkflowSnapshotLike, } from "./types.js";
|
|
10
|
+
export { runSensor } from "./sensor.js";
|
|
11
|
+
export { snapshotFromEnvelope } from "./snapshot.js";
|
|
12
|
+
export { decideFromEnvelope } from "./decide.js";
|
|
13
|
+
export { invocationFromDecision } from "./invoke.js";
|
|
14
|
+
export { judgeUrgency } from "./urgency.js";
|
|
15
|
+
export { checkCloseout } from "./closeout.js";
|
|
16
|
+
export { runConductorLoop, DEFAULT_CONDUCTOR_CONFIG as DEFAULT_CONDUCTOR_CONFIG_LOOP } from "./loop.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conductor module > deterministic sensor → decide → invoke loop.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the public surface so consumers can import everything from one place.
|
|
5
|
+
*
|
|
6
|
+
* Import surface: `../dist/conductor/index.js`
|
|
7
|
+
*/
|
|
8
|
+
export { DEFAULT_CONDUCTOR_CONFIG, mergeAdvanceConfig, } from "./types.js";
|
|
9
|
+
export { runSensor } from "./sensor.js";
|
|
10
|
+
export { snapshotFromEnvelope } from "./snapshot.js";
|
|
11
|
+
export { decideFromEnvelope } from "./decide.js";
|
|
12
|
+
export { invocationFromDecision } from "./invoke.js";
|
|
13
|
+
export { judgeUrgency } from "./urgency.js";
|
|
14
|
+
export { checkCloseout } from "./closeout.js";
|
|
15
|
+
export { runConductorLoop, DEFAULT_CONDUCTOR_CONFIG as DEFAULT_CONDUCTOR_CONFIG_LOOP } from "./loop.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invocationFromDecision > map an invoke decision to /skill:<verb> <args>.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - verb not in commandNames → no-decision-available
|
|
6
|
+
* - precondition check (if any precondition has satisfied:false) → precondition-uncheckable
|
|
7
|
+
* - unattended + review verb → --adversarial flag
|
|
8
|
+
* - unattended + security → --adversarial 3
|
|
9
|
+
*/
|
|
10
|
+
import type { ActionDecision, InvocationOpts, InvocationResult } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Map an invoke decision into an executable invocation string.
|
|
13
|
+
* Returns { ok: true, invocation, adversarial? } or { ok: false, refusal }.
|
|
14
|
+
*/
|
|
15
|
+
export declare function invocationFromDecision(decision: ActionDecision, opts: InvocationOpts): InvocationResult;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invocationFromDecision > map an invoke decision to /skill:<verb> <args>.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - verb not in commandNames → no-decision-available
|
|
6
|
+
* - precondition check (if any precondition has satisfied:false) → precondition-uncheckable
|
|
7
|
+
* - unattended + review verb → --adversarial flag
|
|
8
|
+
* - unattended + security → --adversarial 3
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Map an invoke decision into an executable invocation string.
|
|
12
|
+
* Returns { ok: true, invocation, adversarial? } or { ok: false, refusal }.
|
|
13
|
+
*/
|
|
14
|
+
export function invocationFromDecision(decision, opts) {
|
|
15
|
+
const { commandNames, attended, sensitive, security, continuation } = opts;
|
|
16
|
+
// For non-invoke decisions, return a refusal
|
|
17
|
+
if (decision.kind !== "invoke") {
|
|
18
|
+
return { ok: false, refusal: "no-decision-available", detail: `decision is ${decision.kind}, not invoke` };
|
|
19
|
+
}
|
|
20
|
+
const verb = decision.intent;
|
|
21
|
+
// Check if the verb is a known command
|
|
22
|
+
if (!commandNames.has(verb)) {
|
|
23
|
+
return { ok: false, refusal: "no-decision-available", detail: `command "${verb}" not found` };
|
|
24
|
+
}
|
|
25
|
+
// Check preconditions > if any precondition has satisfied:false → refuse
|
|
26
|
+
if (continuation?.preconditions) {
|
|
27
|
+
const hasUnchecked = continuation.preconditions.some((p) => !p.satisfied);
|
|
28
|
+
if (hasUnchecked) {
|
|
29
|
+
return { ok: false, refusal: "precondition-uncheckable", detail: "unmet precondition(s)" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Build invocation: /skill:<verb> <targets joined with space>
|
|
33
|
+
const targets = decision.targets ?? [];
|
|
34
|
+
let invocation = `/skill:${verb}`;
|
|
35
|
+
if (targets.length > 0) {
|
|
36
|
+
invocation += " " + targets.join(" ");
|
|
37
|
+
}
|
|
38
|
+
// Adversarial flags > only for unattended mode
|
|
39
|
+
let adversarial;
|
|
40
|
+
if (!attended) {
|
|
41
|
+
// Security mode overrides everything
|
|
42
|
+
if (security) {
|
|
43
|
+
adversarial = 3;
|
|
44
|
+
invocation += " --adversarial 3";
|
|
45
|
+
}
|
|
46
|
+
else if (sensitive && isReviewVerb(verb)) {
|
|
47
|
+
adversarial = 2;
|
|
48
|
+
invocation += " --adversarial 2";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, invocation, adversarial };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if an intent is a review-class verb.
|
|
55
|
+
* The tests specifically check for "review-change" as the review verb.
|
|
56
|
+
*/
|
|
57
|
+
function isReviewVerb(verb) {
|
|
58
|
+
return verb.startsWith("review-") || verb === "review-change";
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runConductorLoop — the main deterministic loop (feature 62).
|
|
3
|
+
*
|
|
4
|
+
* Flow per iteration:
|
|
5
|
+
* 1. Sensor (runSensor(cwd, runtimeBin) — Envelope v2, fail-closed)
|
|
6
|
+
* 2. Urgency judge (deterministic short-circuits; INTERRUPT_NOW parks first)
|
|
7
|
+
* 3. Git probe & closeout (consecutive-partial tracking)
|
|
8
|
+
* 4. Decide (decideWorkflowAction over the mapped snapshot)
|
|
9
|
+
* 5. invoke → sendUserMessage(invocation verbatim) | sense (bounded) | stop
|
|
10
|
+
*
|
|
11
|
+
* Stops on: sensor refusal, invocation refusal, stop decision, 3 consecutive
|
|
12
|
+
* partials, 2 consecutive senses without progress, cap exceeded. NEVER sends a
|
|
13
|
+
* merge command — merge authority stays with the human + /audit-pr (feature 20).
|
|
14
|
+
*
|
|
15
|
+
* Run-log line format (SPEC AC10):
|
|
16
|
+
* YYYY-MM-DD HH:MM — <kind/code> — <invocation|stop> — <k>/<cap>
|
|
17
|
+
*/
|
|
18
|
+
import type { ConductorConfig, LoopDeps, LoopResult } from "./types.js";
|
|
19
|
+
/** Default config used when deps.config is not provided. */
|
|
20
|
+
export declare const DEFAULT_CONDUCTOR_CONFIG: ConductorConfig;
|
|
21
|
+
/**
|
|
22
|
+
* Run the conductor loop until convergence, stop, cap, or park.
|
|
23
|
+
* All I/O is injected through deps; the loop itself is deterministic.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runConductorLoop(deps: LoopDeps): Promise<LoopResult>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runConductorLoop — the main deterministic loop (feature 62).
|
|
3
|
+
*
|
|
4
|
+
* Flow per iteration:
|
|
5
|
+
* 1. Sensor (runSensor(cwd, runtimeBin) — Envelope v2, fail-closed)
|
|
6
|
+
* 2. Urgency judge (deterministic short-circuits; INTERRUPT_NOW parks first)
|
|
7
|
+
* 3. Git probe & closeout (consecutive-partial tracking)
|
|
8
|
+
* 4. Decide (decideWorkflowAction over the mapped snapshot)
|
|
9
|
+
* 5. invoke → sendUserMessage(invocation verbatim) | sense (bounded) | stop
|
|
10
|
+
*
|
|
11
|
+
* Stops on: sensor refusal, invocation refusal, stop decision, 3 consecutive
|
|
12
|
+
* partials, 2 consecutive senses without progress, cap exceeded. NEVER sends a
|
|
13
|
+
* merge command — merge authority stays with the human + /audit-pr (feature 20).
|
|
14
|
+
*
|
|
15
|
+
* Run-log line format (SPEC AC10):
|
|
16
|
+
* YYYY-MM-DD HH:MM — <kind/code> — <invocation|stop> — <k>/<cap>
|
|
17
|
+
*/
|
|
18
|
+
import { checkCloseout } from "./closeout.js";
|
|
19
|
+
/** Default config used when deps.config is not provided. */
|
|
20
|
+
export const DEFAULT_CONDUCTOR_CONFIG = {
|
|
21
|
+
iterationsCap: 12,
|
|
22
|
+
sensitivePaths: [],
|
|
23
|
+
securityPaths: [],
|
|
24
|
+
runLogPath: ".agentic-workflow/advance-run.log",
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Fallback command-name set used when deps.commandNames is not provided.
|
|
28
|
+
* The real wiring derives the names from the bundled skills catalogue —
|
|
29
|
+
* this fallback covers the lane's own user-invocable commands only.
|
|
30
|
+
* Retired fixed-pipeline skills and the merge verb are deliberately absent.
|
|
31
|
+
*/
|
|
32
|
+
const FALLBACK_COMMANDS = new Set([
|
|
33
|
+
"audit-docs", "audit-pr", "discover-repository-state", "execute-phase",
|
|
34
|
+
"fold-findings", "init-workspace", "product-audit",
|
|
35
|
+
"resolve-repository-state", "review-change", "triage-issue", "unit-lane",
|
|
36
|
+
"workflow-status",
|
|
37
|
+
]);
|
|
38
|
+
/** Verbs the conductor must never invoke, whatever the decision says. */
|
|
39
|
+
const FORBIDDEN_VERBS = new Set(["merge", "fullauto-merge"]);
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Formatting helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
function formatDate(now) {
|
|
44
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
45
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
46
|
+
}
|
|
47
|
+
function formatLogLine(kind, code, invocation, iteration, cap) {
|
|
48
|
+
return `${formatDate(new Date())} — ${kind}/${code} — ${invocation} — ${iteration}/${cap}`;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Main loop
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Run the conductor loop until convergence, stop, cap, or park.
|
|
55
|
+
* All I/O is injected through deps; the loop itself is deterministic.
|
|
56
|
+
*/
|
|
57
|
+
export async function runConductorLoop(deps) {
|
|
58
|
+
const config = deps.config ?? DEFAULT_CONDUCTOR_CONFIG;
|
|
59
|
+
const cap = config.iterationsCap ?? 12;
|
|
60
|
+
const commandNames = deps.commandNames ?? FALLBACK_COMMANDS;
|
|
61
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
62
|
+
const bin = deps.runtimeBin ?? "bun";
|
|
63
|
+
let consecutivePartials = 0;
|
|
64
|
+
let consecutiveSenses = 0;
|
|
65
|
+
let invoked = 0;
|
|
66
|
+
for (let iteration = 1; iteration <= cap; iteration++) {
|
|
67
|
+
// ── 1. Sensor ───────────────────────────────────────────────────────
|
|
68
|
+
const sensorResult = await deps.runSensor(cwd, bin);
|
|
69
|
+
if (!sensorResult.ok) {
|
|
70
|
+
deps.appendRunLog(formatLogLine("stop", sensorResult.refusal, "sensor-refused", iteration, cap));
|
|
71
|
+
return {
|
|
72
|
+
banner: "ADVANCE: STOPPED",
|
|
73
|
+
stopCode: sensorResult.refusal,
|
|
74
|
+
detail: `${sensorResult.refusal}: ${sensorResult.detail}`,
|
|
75
|
+
iterations: iteration - 1,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const envelope = sensorResult.envelope;
|
|
79
|
+
// ── 2. Banner pre-check: COMPLETE or BLOCKED before any decision (AC8) ──
|
|
80
|
+
// COMPLETE: nothing startable — no recommended action and no blockers
|
|
81
|
+
const nextRecommended = envelope.next?.recommended;
|
|
82
|
+
const blockers = envelope.blockers ?? [];
|
|
83
|
+
if (!nextRecommended && blockers.length === 0) {
|
|
84
|
+
deps.appendRunLog(formatLogLine("stop", "stop-nothing-startable", "nothing-startable", iteration, cap));
|
|
85
|
+
return {
|
|
86
|
+
banner: "ADVANCE: COMPLETE",
|
|
87
|
+
stopCode: "stop-nothing-startable",
|
|
88
|
+
detail: "nothing startable — no recommended action and no blockers",
|
|
89
|
+
iterations: 0,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// BLOCKED: blockers present with unblock information from the envelope
|
|
93
|
+
if (blockers.length > 0) {
|
|
94
|
+
const unblockInfo = blockers
|
|
95
|
+
.map((b) => `${b.kind ?? "unknown"}: ${b.detail ?? ""}`)
|
|
96
|
+
.join("; ");
|
|
97
|
+
deps.appendRunLog(formatLogLine("stop", "stop-blocked", "blocked", iteration, cap));
|
|
98
|
+
return {
|
|
99
|
+
banner: "ADVANCE: BLOCKED",
|
|
100
|
+
stopCode: "stop-blocked",
|
|
101
|
+
detail: `blocked — ${unblockInfo}`,
|
|
102
|
+
iterations: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// ── 3. Urgency judge (deterministic; AC5) ───────────────────────────
|
|
106
|
+
const urgency = deps.judgeUrgency(envelope);
|
|
107
|
+
if (urgency.verdict === "interrupt-now") {
|
|
108
|
+
// Park the in-flight unit, then let the next iteration's sensor route
|
|
109
|
+
// to the urgent issue (the sensor's priority queue already ranks it).
|
|
110
|
+
await deps.parkInFlight();
|
|
111
|
+
deps.appendRunLog(formatLogLine("urgent", "INTERRUPT_NOW", `issue ${urgency.issue ?? "?"}`, iteration, cap));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// finish-first (incl. fix-next head-of-line and the fail-safe default):
|
|
115
|
+
// proceed normally — the sensor ranks the urgent fix for a later iteration.
|
|
116
|
+
// ── 3. Git probe & closeout (AC7) ───────────────────────────────────
|
|
117
|
+
const gitStatus = await deps.gitProbe();
|
|
118
|
+
const closeout = checkCloseout(gitStatus.porcelain, gitStatus.ahead);
|
|
119
|
+
if (closeout === "partial") {
|
|
120
|
+
consecutivePartials++;
|
|
121
|
+
if (consecutivePartials >= 3) {
|
|
122
|
+
deps.appendRunLog(formatLogLine("stop", "parked", "consecutive-partials", iteration, cap));
|
|
123
|
+
return {
|
|
124
|
+
banner: "ADVANCE: STOPPED",
|
|
125
|
+
stopCode: "parked",
|
|
126
|
+
detail: "parked after 3 consecutive partial closeouts",
|
|
127
|
+
iterations: iteration - 1,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
consecutivePartials = 0;
|
|
133
|
+
}
|
|
134
|
+
// ── 4. Decide ───────────────────────────────────────────────────────
|
|
135
|
+
const decision = deps.decide({
|
|
136
|
+
envelope,
|
|
137
|
+
lastOutcome: null,
|
|
138
|
+
lastOutcomeSourceRevision: null,
|
|
139
|
+
policy: deps.policy ?? { allowedIntents: [...FALLBACK_COMMANDS], forgeWriteAuthorized: true },
|
|
140
|
+
});
|
|
141
|
+
// ── 5. Handle decision ──────────────────────────────────────────────
|
|
142
|
+
if (decision.kind === "invoke") {
|
|
143
|
+
const invResult = deps.invocation(decision, {
|
|
144
|
+
commandNames,
|
|
145
|
+
attended: deps.attended,
|
|
146
|
+
sensitive: (config.sensitivePaths?.length ?? 0) > 0,
|
|
147
|
+
security: (config.securityPaths?.length ?? 0) > 0,
|
|
148
|
+
unitScope: envelope.unit
|
|
149
|
+
? {
|
|
150
|
+
type: envelope.unit.type ?? "",
|
|
151
|
+
id: envelope.unit.id ?? "",
|
|
152
|
+
}
|
|
153
|
+
: undefined,
|
|
154
|
+
continuation: envelope.next?.continuation
|
|
155
|
+
? {
|
|
156
|
+
preconditions: envelope.next.continuation
|
|
157
|
+
?.preconditions,
|
|
158
|
+
}
|
|
159
|
+
: undefined,
|
|
160
|
+
});
|
|
161
|
+
if (!invResult.ok) {
|
|
162
|
+
deps.appendRunLog(formatLogLine("stop", invResult.refusal, "invocation-failed", iteration, cap));
|
|
163
|
+
return {
|
|
164
|
+
banner: "ADVANCE: STOPPED",
|
|
165
|
+
stopCode: invResult.refusal,
|
|
166
|
+
detail: invResult.detail ?? invResult.refusal,
|
|
167
|
+
iterations: iteration - 1,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// The mapped invocation is the command of record (AC2/AC6): use it
|
|
171
|
+
// verbatim; only append the decision's targets when the mapping
|
|
172
|
+
// produced a bare verb without arguments.
|
|
173
|
+
const mapped = invResult.invocation;
|
|
174
|
+
const invocation = mapped.includes(" ")
|
|
175
|
+
? mapped
|
|
176
|
+
: [mapped, ...decision.targets].join(" ");
|
|
177
|
+
// Merge authority is never the conductor's (AC9) — belt and braces on
|
|
178
|
+
// top of the command-name refusal.
|
|
179
|
+
const verb = invocation.replace(/^\/skill:/, "").split(" ")[0];
|
|
180
|
+
if (FORBIDDEN_VERBS.has(verb)) {
|
|
181
|
+
deps.appendRunLog(formatLogLine("stop", "stop-policy-denied", invocation, iteration, cap));
|
|
182
|
+
return {
|
|
183
|
+
banner: "ADVANCE: STOPPED",
|
|
184
|
+
stopCode: "stop-policy-denied",
|
|
185
|
+
detail: "merge authority is never the conductor's — /audit-pr + human merge",
|
|
186
|
+
iterations: iteration - 1,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
await deps.sendUserMessage(invocation);
|
|
190
|
+
deps.appendRunLog(formatLogLine("invoke", decision.reasonCode, invocation, iteration, cap));
|
|
191
|
+
// One-stage-per-invocation wiring: the invoked skill must settle before
|
|
192
|
+
// the next sensor run can see its effect, so the default wiring stops
|
|
193
|
+
// here and the operator re-invokes (or passes --continue for the full loop).
|
|
194
|
+
invoked++;
|
|
195
|
+
if (deps.maxInvokes !== undefined && invoked >= deps.maxInvokes) {
|
|
196
|
+
return {
|
|
197
|
+
banner: "ADVANCE: CONTINUE",
|
|
198
|
+
detail: "one stage advanced — re-invoke advance to continue (or --continue for the full loop)",
|
|
199
|
+
iterations: iteration,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (decision.kind === "sense") {
|
|
204
|
+
// Sense is bounded: one re-sense without progress stops the loop (AC4).
|
|
205
|
+
consecutiveSenses++;
|
|
206
|
+
if (consecutiveSenses >= 2) {
|
|
207
|
+
deps.appendRunLog(formatLogLine("stop", decision.reasonCode, "sense-stale", iteration, cap));
|
|
208
|
+
return {
|
|
209
|
+
banner: "ADVANCE: STOPPED",
|
|
210
|
+
stopCode: decision.reasonCode,
|
|
211
|
+
detail: `sense-stale: no progress after re-sense (${decision.detail ?? ""})`,
|
|
212
|
+
iterations: iteration - 1,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
deps.appendRunLog(formatLogLine("sense", decision.reasonCode, "re-sense", iteration, cap));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Stop decision — surface the code and detail to the operator (AC4).
|
|
220
|
+
deps.appendRunLog(formatLogLine("stop", decision.reasonCode, "stop", iteration, cap));
|
|
221
|
+
return {
|
|
222
|
+
banner: "ADVANCE: STOPPED",
|
|
223
|
+
stopCode: decision.reasonCode,
|
|
224
|
+
detail: decision.detail ?? "",
|
|
225
|
+
iterations: iteration - 1,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ── 6. Cap exceeded (AC8) ───────────────────────────────────────────
|
|
230
|
+
return {
|
|
231
|
+
banner: "ADVANCE: STOPPED",
|
|
232
|
+
stopCode: "stop-failed",
|
|
233
|
+
detail: `stop-failed: iterations cap (${cap}) reached without convergence`,
|
|
234
|
+
iterations: cap,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runSensor — spawn the workflow-status sensor, validate its output.
|
|
3
|
+
*
|
|
4
|
+
* Spawns `<bin> scripts/workflow-status.mjs --json-only` in `cwd`,
|
|
5
|
+
* collects stdout, parses JSON, validates against the envelope v2 strict
|
|
6
|
+
* schema, and checks for degradations.
|
|
7
|
+
*
|
|
8
|
+
* Any failure path (spawn error, non-JSON, invalid JSON, schema invalid,
|
|
9
|
+
* degradations present) returns `{ ok: false, refusal: "sensor-degraded", detail }`.
|
|
10
|
+
*/
|
|
11
|
+
import type { SensorResult } from "./types.js";
|
|
12
|
+
export declare function runSensor(cwd: string, bin: "bun" | "node"): Promise<SensorResult>;
|