@deftai/directive-core 0.98.1 → 0.100.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/dist/authz/actions.d.ts +6 -0
- package/dist/authz/actions.js +3 -0
- package/dist/authz/classify.js +401 -75
- package/dist/authz/decompose-apply.d.ts +67 -0
- package/dist/authz/decompose-apply.js +302 -0
- package/dist/authz/index.d.ts +3 -1
- package/dist/authz/index.js +3 -1
- package/dist/authz/store.d.ts +53 -0
- package/dist/authz/store.js +219 -1
- package/dist/authz/types.d.ts +19 -2
- package/dist/authz/types.js +5 -1
- package/dist/consumer-check-contract/evaluate.d.ts +40 -0
- package/dist/consumer-check-contract/evaluate.js +188 -3
- package/dist/consumer-check-contract/index.d.ts +1 -1
- package/dist/consumer-check-contract/index.js +1 -1
- package/dist/content-contracts/skills/greptile-detector.d.ts +42 -0
- package/dist/content-contracts/skills/greptile-detector.js +202 -4
- package/dist/decision/index.d.ts +17 -0
- package/dist/decision/index.js +35 -0
- package/dist/decision/list.d.ts +47 -0
- package/dist/decision/list.js +250 -0
- package/dist/decision/schema.d.ts +88 -0
- package/dist/decision/schema.js +293 -0
- package/dist/decision/write.d.ts +82 -0
- package/dist/decision/write.js +427 -0
- package/dist/doctor/checks.d.ts +44 -0
- package/dist/doctor/checks.js +428 -2
- package/dist/doctor/main.d.ts +6 -7
- package/dist/doctor/main.js +72 -79
- package/dist/doctor/which.d.ts +20 -1
- package/dist/doctor/which.js +67 -1
- package/dist/eval/report.d.ts +29 -0
- package/dist/eval/report.js +69 -0
- package/dist/eval/run.d.ts +9 -0
- package/dist/eval/run.js +40 -4
- package/dist/eval/version-pin.d.ts +99 -0
- package/dist/eval/version-pin.js +181 -0
- package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
- package/dist/finish-loop/pr-finish-loop.js +116 -5
- package/dist/finish-loop/types.d.ts +1 -1
- package/dist/hooks/dispatcher.d.ts +9 -5
- package/dist/hooks/dispatcher.js +23 -15
- package/dist/hooks/readonly.d.ts +6 -3
- package/dist/hooks/readonly.js +44 -25
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init-deposit/agent-hooks.d.ts +20 -0
- package/dist/init-deposit/agent-hooks.js +51 -31
- package/dist/intake/issue-ingest.js +29 -0
- package/dist/lifecycle/completed-consistency.d.ts +60 -0
- package/dist/lifecycle/completed-consistency.js +443 -0
- package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
- package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/literal-acceptance/capture.d.ts +52 -0
- package/dist/literal-acceptance/capture.js +658 -0
- package/dist/literal-acceptance/evaluate.d.ts +47 -0
- package/dist/literal-acceptance/evaluate.js +193 -0
- package/dist/literal-acceptance/index.d.ts +12 -0
- package/dist/literal-acceptance/index.js +12 -0
- package/dist/literal-acceptance/run.d.ts +38 -0
- package/dist/literal-acceptance/run.js +216 -0
- package/dist/literal-acceptance/safety.d.ts +24 -0
- package/dist/literal-acceptance/safety.js +251 -0
- package/dist/literal-acceptance/types.d.ts +82 -0
- package/dist/literal-acceptance/types.js +26 -0
- package/dist/platform/host-content-surface.d.ts +74 -0
- package/dist/platform/host-content-surface.js +214 -0
- package/dist/platform/index.d.ts +1 -0
- package/dist/platform/index.js +1 -0
- package/dist/policy/ceremony-dial.d.ts +289 -0
- package/dist/policy/ceremony-dial.js +980 -0
- package/dist/policy/deft-directive-disable.js +12 -2
- package/dist/policy/index.d.ts +2 -0
- package/dist/policy/index.js +16 -1
- package/dist/policy/merge-approval-head.d.ts +129 -0
- package/dist/policy/merge-approval-head.js +461 -0
- package/dist/policy/require-human-merge.d.ts +5 -0
- package/dist/policy/require-human-merge.js +5 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
- package/dist/pr-merge-readiness/ci-gate.js +65 -5
- package/dist/pr-merge-readiness/compute.d.ts +13 -0
- package/dist/pr-merge-readiness/compute.js +99 -14
- package/dist/pr-merge-readiness/evaluate.js +10 -0
- package/dist/pr-merge-readiness/gh.d.ts +49 -0
- package/dist/pr-merge-readiness/gh.js +268 -0
- package/dist/pr-merge-readiness/index.d.ts +2 -2
- package/dist/pr-merge-readiness/index.js +2 -2
- package/dist/pr-merge-readiness/mergeability.js +5 -0
- package/dist/pr-merge-readiness/output.js +2 -0
- package/dist/pr-merge-readiness/parse.js +4 -0
- package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
- package/dist/pr-merge-readiness/types.d.ts +6 -0
- package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
- package/dist/pr-wait-mergeable/cascade.js +115 -1
- package/dist/pr-wait-mergeable/main.d.ts +4 -0
- package/dist/pr-wait-mergeable/main.js +4 -0
- package/dist/pr-wait-mergeable/types.d.ts +3 -1
- package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
- package/dist/pr-wait-mergeable/wrappers.js +7 -1
- package/dist/preflight/evaluate.d.ts +15 -1
- package/dist/preflight/evaluate.js +33 -2
- package/dist/render/framework-commands.js +4 -0
- package/dist/scope/acceptance-evidence.d.ts +76 -0
- package/dist/scope/acceptance-evidence.js +348 -0
- package/dist/scope/coverage-map.d.ts +98 -0
- package/dist/scope/coverage-map.js +558 -0
- package/dist/scope/decompose.js +125 -11
- package/dist/scope/effort-activate-gate.d.ts +28 -0
- package/dist/scope/effort-activate-gate.js +64 -0
- package/dist/scope/index.d.ts +4 -0
- package/dist/scope/index.js +4 -0
- package/dist/scope/parent-lineage.d.ts +125 -0
- package/dist/scope/parent-lineage.js +626 -0
- package/dist/scope/transition.d.ts +8 -0
- package/dist/scope/transition.js +69 -2
- package/dist/session/active-cli.d.ts +79 -0
- package/dist/session/active-cli.js +382 -0
- package/dist/session/effort-budget.d.ts +130 -0
- package/dist/session/effort-budget.js +334 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/session-start.d.ts +30 -1
- package/dist/session/session-start.js +234 -26
- package/dist/session/verify-session-ritual.d.ts +14 -0
- package/dist/session/verify-session-ritual.js +33 -0
- package/dist/story-ready/evaluate.d.ts +11 -0
- package/dist/story-ready/evaluate.js +41 -3
- package/dist/swarm/index.d.ts +2 -0
- package/dist/swarm/index.js +2 -0
- package/dist/swarm/pre-dispatch-cli.d.ts +19 -0
- package/dist/swarm/pre-dispatch-cli.js +143 -0
- package/dist/swarm/pre-dispatch.d.ts +87 -0
- package/dist/swarm/pre-dispatch.js +373 -0
- package/dist/triage/actions/candidates-log.d.ts +1 -2
- package/dist/triage/actions/candidates-log.js +37 -6
- package/dist/vbrief-activate/activate.js +6 -0
- package/dist/vbrief-validate/constants.d.ts +2 -0
- package/dist/vbrief-validate/constants.js +2 -0
- package/dist/vbrief-validate/schema.js +4 -1
- package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
- package/dist/xbrief-migrate/migrate-project.js +92 -3
- package/package.json +19 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI for task swarm:pre-dispatch (#3228).
|
|
4
|
+
*
|
|
5
|
+
* Exit codes:
|
|
6
|
+
* 0 — allow (begin succeeded / complete-cancel succeeded)
|
|
7
|
+
* 1 — gate deny (e.g. DENY_DUPLICATE_ACTIVE) or complete with no active attempt
|
|
8
|
+
* 2 — config / usage error
|
|
9
|
+
*/
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { EXIT_CONFIG_ERROR } from "./constants.js";
|
|
13
|
+
import { COMPLETE_STATUSES, formatPreDispatchReport, PRE_DISPATCH_ACTIONS, swarmPreDispatch, } from "./pre-dispatch.js";
|
|
14
|
+
export function parsePreDispatchArgv(argv) {
|
|
15
|
+
const out = {
|
|
16
|
+
projectRoot: ".",
|
|
17
|
+
scopeId: null,
|
|
18
|
+
targetId: null,
|
|
19
|
+
workflowId: null,
|
|
20
|
+
action: null,
|
|
21
|
+
sourceRevision: null,
|
|
22
|
+
attemptId: null,
|
|
23
|
+
status: null,
|
|
24
|
+
workerId: null,
|
|
25
|
+
externalRunId: null,
|
|
26
|
+
json: false,
|
|
27
|
+
help: false,
|
|
28
|
+
};
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
const arg = argv[i];
|
|
31
|
+
if (arg === "--help" || arg === "-h") {
|
|
32
|
+
out.help = true;
|
|
33
|
+
}
|
|
34
|
+
else if (arg === "--json") {
|
|
35
|
+
out.json = true;
|
|
36
|
+
}
|
|
37
|
+
else if (arg === "--project-root" && argv[i + 1] !== undefined) {
|
|
38
|
+
out.projectRoot = argv[i + 1] ?? ".";
|
|
39
|
+
i += 1;
|
|
40
|
+
}
|
|
41
|
+
else if ((arg === "--scope-id" || arg === "--scope") && argv[i + 1] !== undefined) {
|
|
42
|
+
out.scopeId = argv[i + 1] ?? null;
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
else if ((arg === "--target-id" || arg === "--target") && argv[i + 1] !== undefined) {
|
|
46
|
+
out.targetId = argv[i + 1] ?? null;
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
else if ((arg === "--workflow-id" || arg === "--workflow") && argv[i + 1] !== undefined) {
|
|
50
|
+
out.workflowId = argv[i + 1] ?? null;
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
else if (arg === "--action" && argv[i + 1] !== undefined) {
|
|
54
|
+
out.action = (argv[i + 1] ?? null);
|
|
55
|
+
i += 1;
|
|
56
|
+
}
|
|
57
|
+
else if ((arg === "--source-revision" || arg === "--revision") && argv[i + 1] !== undefined) {
|
|
58
|
+
out.sourceRevision = argv[i + 1] ?? null;
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
else if (arg === "--attempt-id" && argv[i + 1] !== undefined) {
|
|
62
|
+
out.attemptId = argv[i + 1] ?? null;
|
|
63
|
+
i += 1;
|
|
64
|
+
}
|
|
65
|
+
else if (arg === "--status" && argv[i + 1] !== undefined) {
|
|
66
|
+
out.status = (argv[i + 1] ?? null);
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
else if (arg === "--worker-id" && argv[i + 1] !== undefined) {
|
|
70
|
+
out.workerId = argv[i + 1] ?? null;
|
|
71
|
+
i += 1;
|
|
72
|
+
}
|
|
73
|
+
else if (arg === "--external-run-id" && argv[i + 1] !== undefined) {
|
|
74
|
+
out.externalRunId = argv[i + 1] ?? null;
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const USAGE = `Usage: task swarm:pre-dispatch -- --scope-id <id> --target-id <worktree|branch> [options]
|
|
81
|
+
|
|
82
|
+
Pre-dispatch gate for implement leaves (#3228 / #3143 DENY_DUPLICATE_ACTIVE).
|
|
83
|
+
Before spawning a peer implement leaf: run with default --action begin.
|
|
84
|
+
exit 0 allow (attempt begun)
|
|
85
|
+
exit 1 active deny / gate block (do not spawn)
|
|
86
|
+
exit 2 config / usage error
|
|
87
|
+
|
|
88
|
+
Takeover: --action cancel, then pre-dispatch begin again (not concurrent dual active).
|
|
89
|
+
Terminal: --action complete [--status succeeded|failed|cancelled|blocked]
|
|
90
|
+
|
|
91
|
+
Options:
|
|
92
|
+
--scope-id, --scope <id> Unit scope (story/issue or xBRIEF plan id)
|
|
93
|
+
--target-id, --target <id> Unit target (worktree path or branch)
|
|
94
|
+
--workflow-id, --workflow <id> Default: drive-to:merge-ready
|
|
95
|
+
--action begin|complete|cancel Default: begin
|
|
96
|
+
--source-revision <sha> Default: git rev-parse HEAD
|
|
97
|
+
--attempt-id <id> For complete/cancel
|
|
98
|
+
--status <status> For complete: ${COMPLETE_STATUSES.join("|")}
|
|
99
|
+
--worker-id <id> Optional worker stamp on begin
|
|
100
|
+
--external-run-id <id> Optional external run id
|
|
101
|
+
--project-root <path> Project root (ledger under .deft/delivery-attempts/)
|
|
102
|
+
--json Machine-readable result on stdout
|
|
103
|
+
-h, --help Show this help
|
|
104
|
+
`;
|
|
105
|
+
export function preDispatchMain(argv = process.argv.slice(2)) {
|
|
106
|
+
const parsed = parsePreDispatchArgv(argv);
|
|
107
|
+
if (parsed.help) {
|
|
108
|
+
process.stdout.write(USAGE);
|
|
109
|
+
return EXIT_CONFIG_ERROR;
|
|
110
|
+
}
|
|
111
|
+
if (parsed.action !== null &&
|
|
112
|
+
!PRE_DISPATCH_ACTIONS.includes(parsed.action)) {
|
|
113
|
+
process.stderr.write(`Error: --action must be one of: ${PRE_DISPATCH_ACTIONS.join(", ")}.\n`);
|
|
114
|
+
return EXIT_CONFIG_ERROR;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.status !== null && !COMPLETE_STATUSES.includes(parsed.status)) {
|
|
117
|
+
process.stderr.write(`Error: --status must be one of: ${COMPLETE_STATUSES.join(", ")}.\n`);
|
|
118
|
+
return EXIT_CONFIG_ERROR;
|
|
119
|
+
}
|
|
120
|
+
const result = swarmPreDispatch({
|
|
121
|
+
projectRoot: resolve(parsed.projectRoot),
|
|
122
|
+
scopeId: parsed.scopeId ?? "",
|
|
123
|
+
targetId: parsed.targetId ?? "",
|
|
124
|
+
workflowId: parsed.workflowId ?? undefined,
|
|
125
|
+
action: parsed.action ?? undefined,
|
|
126
|
+
sourceRevision: parsed.sourceRevision ?? undefined,
|
|
127
|
+
attemptId: parsed.attemptId ?? undefined,
|
|
128
|
+
status: parsed.status ?? undefined,
|
|
129
|
+
workerId: parsed.workerId,
|
|
130
|
+
externalRunId: parsed.externalRunId,
|
|
131
|
+
});
|
|
132
|
+
if (parsed.json) {
|
|
133
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
process.stdout.write(`${formatPreDispatchReport(result)}\n`);
|
|
137
|
+
}
|
|
138
|
+
return result.exitCode;
|
|
139
|
+
}
|
|
140
|
+
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
141
|
+
process.exit(preDispatchMain());
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=pre-dispatch-cli.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm implement-leaf pre-dispatch gate (#3228).
|
|
3
|
+
*
|
|
4
|
+
* Wires #3143 delivery-attempt DENY_DUPLICATE_ACTIVE onto the swarm re-dispatch
|
|
5
|
+
* path: before starting a peer implement leaf on a unit, if a non-terminal
|
|
6
|
+
* attempt already exists → exit non-zero and do not spawn.
|
|
7
|
+
*
|
|
8
|
+
* Takeover is two steps: cancel (complete status=cancelled) the prior attempt,
|
|
9
|
+
* then pre-dispatch begin again — never concurrent dual active.
|
|
10
|
+
*/
|
|
11
|
+
import { type AttemptTrigger, type DeliveryAttemptRecord, type PreDispatchDecision } from "../delivery-attempt/index.js";
|
|
12
|
+
import { EXIT_CONFIG_ERROR, EXIT_GATE_FAILED, EXIT_OK } from "./constants.js";
|
|
13
|
+
/** Default workflow id for drive-to:merge-ready implement leaves. */
|
|
14
|
+
export declare const IMPLEMENT_LEAF_WORKFLOW_ID = "drive-to:merge-ready";
|
|
15
|
+
export declare const PRE_DISPATCH_ACTIONS: readonly ["begin", "complete", "cancel"];
|
|
16
|
+
export type PreDispatchAction = (typeof PRE_DISPATCH_ACTIONS)[number];
|
|
17
|
+
export declare const COMPLETE_STATUSES: readonly ["succeeded", "failed", "cancelled", "blocked"];
|
|
18
|
+
export type CompleteStatus = (typeof COMPLETE_STATUSES)[number];
|
|
19
|
+
export interface SwarmPreDispatchInput {
|
|
20
|
+
readonly projectRoot: string;
|
|
21
|
+
readonly scopeId: string;
|
|
22
|
+
readonly targetId: string;
|
|
23
|
+
readonly workflowId?: string;
|
|
24
|
+
readonly action?: PreDispatchAction;
|
|
25
|
+
readonly sourceRevision?: string;
|
|
26
|
+
readonly attemptId?: string;
|
|
27
|
+
readonly status?: CompleteStatus;
|
|
28
|
+
readonly workerId?: string | null;
|
|
29
|
+
readonly externalRunId?: string | null;
|
|
30
|
+
readonly trigger?: AttemptTrigger;
|
|
31
|
+
readonly now?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SwarmPreDispatchResult {
|
|
34
|
+
readonly exitCode: typeof EXIT_OK | typeof EXIT_GATE_FAILED | typeof EXIT_CONFIG_ERROR;
|
|
35
|
+
readonly decision: PreDispatchDecision | null;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
readonly action: PreDispatchAction;
|
|
38
|
+
readonly scopeId: string;
|
|
39
|
+
readonly targetId: string;
|
|
40
|
+
readonly workflowId: string;
|
|
41
|
+
readonly attempt: DeliveryAttemptRecord | null;
|
|
42
|
+
readonly activeAttemptIds: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
export declare function resolveSourceRevision(projectRoot: string, explicit?: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* True when targetId should be treated as a filesystem path (worktree), not an
|
|
47
|
+
* opaque branch/ref id. Branch names with `/` (e.g. `feat/foo`) stay opaque.
|
|
48
|
+
*/
|
|
49
|
+
export declare function looksLikeFilesystemTarget(targetId: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Canonical unit target for ledger keys so relative/absolute/separator/case
|
|
52
|
+
* variants of the same worktree do not split gate state (#3228 Greptile P1).
|
|
53
|
+
*
|
|
54
|
+
* Always resolve under projectRoot to a stable absolute lexical key (even
|
|
55
|
+
* before the path exists). Do **not** realpath: following a symlink that is
|
|
56
|
+
* created between dispatches would change the key and split ledgers.
|
|
57
|
+
* Case-fold prevents case-insensitive FS splits. Existence never changes
|
|
58
|
+
* the ledger key.
|
|
59
|
+
*/
|
|
60
|
+
export declare function normalizeTargetId(projectRoot: string, targetId: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Best-effort physical identity for alias peer checks. Ledger keys stay lexical
|
|
63
|
+
* (stable); when a path exists, realpath groups symlink aliases that point at
|
|
64
|
+
* the same worktree so sequential begins still DENY.
|
|
65
|
+
*/
|
|
66
|
+
export declare function physicalTargetKey(projectRoot: string, targetId: string): string;
|
|
67
|
+
/** True when another unit (same scope+workflow, different target key) is active on same physical path. */
|
|
68
|
+
export declare function hasActiveAliasPeer(projectRoot: string, scopeId: string, targetId: string, workflowId: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Parse beginAttemptOnDisk-style error messages without a ReDoS-prone regex
|
|
71
|
+
* (CodeQL: polynomial regex on uncontrolled data).
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseDecisionFromError(message: string): {
|
|
74
|
+
decision: PreDispatchDecision | null;
|
|
75
|
+
reason: string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Swarm pre-dispatch gate for implement leaves.
|
|
79
|
+
*
|
|
80
|
+
* - begin (default): evaluate #3143 gate; on allow, beginAttempt; exit 0 / 1 / 2
|
|
81
|
+
* - complete: terminal success/fail/blocked
|
|
82
|
+
* - cancel: terminal cancel (takeover step 1)
|
|
83
|
+
*/
|
|
84
|
+
export declare function swarmPreDispatch(input: SwarmPreDispatchInput): SwarmPreDispatchResult;
|
|
85
|
+
/** Human-readable one-line report for CLI stdout. */
|
|
86
|
+
export declare function formatPreDispatchReport(result: SwarmPreDispatchResult): string;
|
|
87
|
+
//# sourceMappingURL=pre-dispatch.d.ts.map
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm implement-leaf pre-dispatch gate (#3228).
|
|
3
|
+
*
|
|
4
|
+
* Wires #3143 delivery-attempt DENY_DUPLICATE_ACTIVE onto the swarm re-dispatch
|
|
5
|
+
* path: before starting a peer implement leaf on a unit, if a non-terminal
|
|
6
|
+
* attempt already exists → exit non-zero and do not spawn.
|
|
7
|
+
*
|
|
8
|
+
* Takeover is two steps: cancel (complete status=cancelled) the prior attempt,
|
|
9
|
+
* then pre-dispatch begin again — never concurrent dual active.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
12
|
+
import { isAbsolute, normalize, resolve } from "node:path";
|
|
13
|
+
import { activeAttempts, beginAttempt, completeAttemptOnDisk, evaluatePreDispatch, hasActiveAttempt, listUnitLedgers, loadOrCreateUnitLedger, loadUnitLedger, markBlocked, saveUnitLedger, withUnitLock, } from "../delivery-attempt/index.js";
|
|
14
|
+
import { EXIT_CONFIG_ERROR, EXIT_GATE_FAILED, EXIT_OK } from "./constants.js";
|
|
15
|
+
import { runText } from "./subprocess.js";
|
|
16
|
+
/** Default workflow id for drive-to:merge-ready implement leaves. */
|
|
17
|
+
export const IMPLEMENT_LEAF_WORKFLOW_ID = "drive-to:merge-ready";
|
|
18
|
+
export const PRE_DISPATCH_ACTIONS = ["begin", "complete", "cancel"];
|
|
19
|
+
export const COMPLETE_STATUSES = ["succeeded", "failed", "cancelled", "blocked"];
|
|
20
|
+
export function resolveSourceRevision(projectRoot, explicit) {
|
|
21
|
+
if (explicit !== undefined && explicit.trim().length > 0) {
|
|
22
|
+
return explicit.trim();
|
|
23
|
+
}
|
|
24
|
+
const captured = runText(["git", "rev-parse", "HEAD"], { cwd: projectRoot });
|
|
25
|
+
if (captured.returncode === 0) {
|
|
26
|
+
const sha = captured.stdout.trim();
|
|
27
|
+
if (sha.length > 0)
|
|
28
|
+
return sha;
|
|
29
|
+
}
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when targetId should be treated as a filesystem path (worktree), not an
|
|
34
|
+
* opaque branch/ref id. Branch names with `/` (e.g. `feat/foo`) stay opaque.
|
|
35
|
+
*/
|
|
36
|
+
export function looksLikeFilesystemTarget(targetId) {
|
|
37
|
+
const t = targetId.trim();
|
|
38
|
+
if (t.length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
if (isAbsolute(t) || t.startsWith(".") || t.includes("\\"))
|
|
41
|
+
return true;
|
|
42
|
+
if (/^[A-Za-z]:[\\/]/.test(t))
|
|
43
|
+
return true;
|
|
44
|
+
const lower = t.toLowerCase().replace(/\\/g, "/");
|
|
45
|
+
return (lower.includes(".deft-scratch/") ||
|
|
46
|
+
lower.includes("/worktrees/") ||
|
|
47
|
+
lower.startsWith("worktrees/"));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Canonical unit target for ledger keys so relative/absolute/separator/case
|
|
51
|
+
* variants of the same worktree do not split gate state (#3228 Greptile P1).
|
|
52
|
+
*
|
|
53
|
+
* Always resolve under projectRoot to a stable absolute lexical key (even
|
|
54
|
+
* before the path exists). Do **not** realpath: following a symlink that is
|
|
55
|
+
* created between dispatches would change the key and split ledgers.
|
|
56
|
+
* Case-fold prevents case-insensitive FS splits. Existence never changes
|
|
57
|
+
* the ledger key.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeTargetId(projectRoot, targetId) {
|
|
60
|
+
const trimmed = targetId.trim();
|
|
61
|
+
if (trimmed.length === 0)
|
|
62
|
+
return trimmed;
|
|
63
|
+
let pathKey = normalize(resolve(projectRoot, trimmed)).replace(/\\/g, "/").toLowerCase();
|
|
64
|
+
if (pathKey.length > 1 && pathKey.endsWith("/")) {
|
|
65
|
+
pathKey = pathKey.slice(0, -1);
|
|
66
|
+
}
|
|
67
|
+
return pathKey;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort physical identity for alias peer checks. Ledger keys stay lexical
|
|
71
|
+
* (stable); when a path exists, realpath groups symlink aliases that point at
|
|
72
|
+
* the same worktree so sequential begins still DENY.
|
|
73
|
+
*/
|
|
74
|
+
export function physicalTargetKey(projectRoot, targetId) {
|
|
75
|
+
const lexical = normalizeTargetId(projectRoot, targetId);
|
|
76
|
+
const candidates = [lexical, resolve(projectRoot, targetId.trim()), targetId.trim()];
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (candidate.length === 0 || !existsSync(candidate))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
return normalize(realpathSync(candidate)).replace(/\\/g, "/").toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* try next candidate */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return lexical;
|
|
88
|
+
}
|
|
89
|
+
/** True when another unit (same scope+workflow, different target key) is active on same physical path. */
|
|
90
|
+
export function hasActiveAliasPeer(projectRoot, scopeId, targetId, workflowId) {
|
|
91
|
+
const mine = physicalTargetKey(projectRoot, targetId);
|
|
92
|
+
for (const ledger of listUnitLedgers(projectRoot)) {
|
|
93
|
+
if (ledger.scopeId !== scopeId || ledger.workflowId !== workflowId)
|
|
94
|
+
continue;
|
|
95
|
+
if (ledger.targetId === targetId)
|
|
96
|
+
continue;
|
|
97
|
+
if (!hasActiveAttempt(ledger))
|
|
98
|
+
continue;
|
|
99
|
+
if (physicalTargetKey(projectRoot, ledger.targetId) === mine) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
function unitFields(input) {
|
|
106
|
+
return {
|
|
107
|
+
scopeId: input.scopeId.trim(),
|
|
108
|
+
targetId: normalizeTargetId(input.projectRoot, input.targetId),
|
|
109
|
+
workflowId: (input.workflowId ?? IMPLEMENT_LEAF_WORKFLOW_ID).trim(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function baseResult(input, action, partial) {
|
|
113
|
+
const { scopeId, targetId, workflowId } = unitFields(input);
|
|
114
|
+
return {
|
|
115
|
+
exitCode: partial.exitCode,
|
|
116
|
+
decision: partial.decision,
|
|
117
|
+
reason: partial.reason,
|
|
118
|
+
action,
|
|
119
|
+
scopeId,
|
|
120
|
+
targetId,
|
|
121
|
+
workflowId,
|
|
122
|
+
attempt: partial.attempt ?? null,
|
|
123
|
+
activeAttemptIds: partial.activeAttemptIds ?? [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function listActiveIds(projectRoot, scopeId, targetId, workflowId) {
|
|
127
|
+
const ledger = loadUnitLedger(projectRoot, scopeId, targetId, workflowId);
|
|
128
|
+
if (ledger === null)
|
|
129
|
+
return [];
|
|
130
|
+
return activeAttempts(ledger).map((a) => a.attemptId);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Parse beginAttemptOnDisk-style error messages without a ReDoS-prone regex
|
|
134
|
+
* (CodeQL: polynomial regex on uncontrolled data).
|
|
135
|
+
*/
|
|
136
|
+
export function parseDecisionFromError(message) {
|
|
137
|
+
const prefix = "delivery-attempt ";
|
|
138
|
+
if (!message.startsWith(prefix)) {
|
|
139
|
+
return { decision: null, reason: message };
|
|
140
|
+
}
|
|
141
|
+
const rest = message.slice(prefix.length);
|
|
142
|
+
const colon = rest.indexOf(":");
|
|
143
|
+
if (colon <= 0) {
|
|
144
|
+
return { decision: null, reason: message };
|
|
145
|
+
}
|
|
146
|
+
const code = rest.slice(0, colon).trim();
|
|
147
|
+
// Bound check: decision codes are short fixed tokens (ALLOW_*/DENY_*/BLOCK_*).
|
|
148
|
+
if (code.length > 64 || code.length < 6) {
|
|
149
|
+
return { decision: null, reason: message };
|
|
150
|
+
}
|
|
151
|
+
if (!code.startsWith("ALLOW_") && !code.startsWith("DENY_") && !code.startsWith("BLOCK_")) {
|
|
152
|
+
return { decision: null, reason: message };
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
155
|
+
const c = code.charCodeAt(i);
|
|
156
|
+
const ok = (c >= 65 && c <= 90) || // A-Z
|
|
157
|
+
(c >= 48 && c <= 57) || // 0-9
|
|
158
|
+
c === 95; // _
|
|
159
|
+
if (!ok) {
|
|
160
|
+
return { decision: null, reason: message };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const reason = rest.slice(colon + 1).trim();
|
|
164
|
+
return {
|
|
165
|
+
decision: code,
|
|
166
|
+
reason: reason.length > 0 ? reason : message,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function runBegin(input) {
|
|
170
|
+
const { scopeId, targetId, workflowId } = unitFields(input);
|
|
171
|
+
const sourceRevision = resolveSourceRevision(input.projectRoot, input.sourceRevision);
|
|
172
|
+
const trigger = input.trigger ?? "automatic";
|
|
173
|
+
// Exclusive lock → reload → evaluate → begin+save (same decision under lock;
|
|
174
|
+
// no stale preview decision on the success path).
|
|
175
|
+
try {
|
|
176
|
+
return withUnitLock(input.projectRoot, scopeId, targetId, workflowId, () => {
|
|
177
|
+
// Symlink alias peer: different lexical keys, same physical worktree.
|
|
178
|
+
if (hasActiveAliasPeer(input.projectRoot, scopeId, targetId, workflowId)) {
|
|
179
|
+
return baseResult(input, "begin", {
|
|
180
|
+
exitCode: EXIT_GATE_FAILED,
|
|
181
|
+
decision: "DENY_DUPLICATE_ACTIVE",
|
|
182
|
+
reason: "active attempt exists on alias/realpath peer of target",
|
|
183
|
+
activeAttemptIds: listActiveIds(input.projectRoot, scopeId, targetId, workflowId),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const current = loadOrCreateUnitLedger(input.projectRoot, {
|
|
187
|
+
scopeId,
|
|
188
|
+
targetId,
|
|
189
|
+
workflowId,
|
|
190
|
+
now: input.now,
|
|
191
|
+
});
|
|
192
|
+
const decision = evaluatePreDispatch(current, {
|
|
193
|
+
scopeId,
|
|
194
|
+
targetId,
|
|
195
|
+
workflowId,
|
|
196
|
+
sourceRevision,
|
|
197
|
+
trigger,
|
|
198
|
+
now: input.now,
|
|
199
|
+
});
|
|
200
|
+
if (!decision.allowed) {
|
|
201
|
+
if (decision.handoff !== null) {
|
|
202
|
+
const blocked = markBlocked(current, decision.decision, decision.handoff.resumeCondition, input.now);
|
|
203
|
+
saveUnitLedger(input.projectRoot, blocked);
|
|
204
|
+
}
|
|
205
|
+
return baseResult(input, "begin", {
|
|
206
|
+
exitCode: EXIT_GATE_FAILED,
|
|
207
|
+
decision: decision.decision,
|
|
208
|
+
reason: decision.reason,
|
|
209
|
+
activeAttemptIds: activeAttempts(current).map((a) => a.attemptId),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const { ledger, attempt } = beginAttempt(current, {
|
|
213
|
+
sourceRevision,
|
|
214
|
+
trigger,
|
|
215
|
+
status: "running",
|
|
216
|
+
workerId: input.workerId ?? null,
|
|
217
|
+
externalRunId: input.externalRunId ?? null,
|
|
218
|
+
now: input.now,
|
|
219
|
+
consumeOverride: decision.decision === "ALLOW_OVERRIDE",
|
|
220
|
+
});
|
|
221
|
+
saveUnitLedger(input.projectRoot, ledger);
|
|
222
|
+
return baseResult(input, "begin", {
|
|
223
|
+
exitCode: EXIT_OK,
|
|
224
|
+
decision: decision.decision,
|
|
225
|
+
reason: `allowed; attempt ${attempt.attemptId} begun`,
|
|
226
|
+
attempt,
|
|
227
|
+
activeAttemptIds: [attempt.attemptId],
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
const parsed = parseDecisionFromError(message);
|
|
234
|
+
if (parsed.decision !== null) {
|
|
235
|
+
return baseResult(input, "begin", {
|
|
236
|
+
exitCode: EXIT_GATE_FAILED,
|
|
237
|
+
decision: parsed.decision,
|
|
238
|
+
reason: parsed.reason,
|
|
239
|
+
activeAttemptIds: listActiveIds(input.projectRoot, scopeId, targetId, workflowId),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return baseResult(input, "begin", {
|
|
243
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
244
|
+
decision: null,
|
|
245
|
+
reason: message,
|
|
246
|
+
activeAttemptIds: listActiveIds(input.projectRoot, scopeId, targetId, workflowId),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function runComplete(input, action) {
|
|
251
|
+
const { scopeId, targetId, workflowId } = unitFields(input);
|
|
252
|
+
const status = action === "cancel" ? "cancelled" : (input.status ?? "succeeded");
|
|
253
|
+
const ledger = loadUnitLedger(input.projectRoot, scopeId, targetId, workflowId);
|
|
254
|
+
if (ledger === null) {
|
|
255
|
+
return baseResult(input, action, {
|
|
256
|
+
exitCode: EXIT_GATE_FAILED,
|
|
257
|
+
decision: null,
|
|
258
|
+
reason: "no delivery-attempt ledger for unit",
|
|
259
|
+
activeAttemptIds: [],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const actives = activeAttempts(ledger);
|
|
263
|
+
if (actives.length === 0 && input.attemptId === undefined && input.externalRunId === undefined) {
|
|
264
|
+
return baseResult(input, action, {
|
|
265
|
+
exitCode: EXIT_GATE_FAILED,
|
|
266
|
+
decision: null,
|
|
267
|
+
reason: "no active attempt to complete/cancel",
|
|
268
|
+
activeAttemptIds: [],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const next = completeAttemptOnDisk(input.projectRoot, {
|
|
273
|
+
scopeId,
|
|
274
|
+
targetId,
|
|
275
|
+
workflowId,
|
|
276
|
+
attemptId: input.attemptId,
|
|
277
|
+
externalRunId: input.externalRunId ?? null,
|
|
278
|
+
status,
|
|
279
|
+
now: input.now,
|
|
280
|
+
});
|
|
281
|
+
const closed = next.attempts.find((a) => a.attemptId === input.attemptId) ??
|
|
282
|
+
next.attempts.filter((a) => a.endedAt !== null).at(-1) ??
|
|
283
|
+
null;
|
|
284
|
+
return baseResult(input, action, {
|
|
285
|
+
exitCode: EXIT_OK,
|
|
286
|
+
decision: null,
|
|
287
|
+
reason: `attempt ${closed?.attemptId ?? "?"} marked ${status}`,
|
|
288
|
+
attempt: closed,
|
|
289
|
+
activeAttemptIds: activeAttempts(next).map((a) => a.attemptId),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
294
|
+
return baseResult(input, action, {
|
|
295
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
296
|
+
decision: null,
|
|
297
|
+
reason: message,
|
|
298
|
+
activeAttemptIds: listActiveIds(input.projectRoot, scopeId, targetId, workflowId),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Swarm pre-dispatch gate for implement leaves.
|
|
304
|
+
*
|
|
305
|
+
* - begin (default): evaluate #3143 gate; on allow, beginAttempt; exit 0 / 1 / 2
|
|
306
|
+
* - complete: terminal success/fail/blocked
|
|
307
|
+
* - cancel: terminal cancel (takeover step 1)
|
|
308
|
+
*/
|
|
309
|
+
export function swarmPreDispatch(input) {
|
|
310
|
+
const action = input.action ?? "begin";
|
|
311
|
+
const { scopeId, targetId, workflowId } = unitFields(input);
|
|
312
|
+
if (scopeId.length === 0) {
|
|
313
|
+
return baseResult(input, action, {
|
|
314
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
315
|
+
decision: null,
|
|
316
|
+
reason: "--scope-id is required (story/issue or xBRIEF plan id)",
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (targetId.length === 0) {
|
|
320
|
+
return baseResult(input, action, {
|
|
321
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
322
|
+
decision: null,
|
|
323
|
+
reason: "--target-id is required (worktree path or branch)",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (workflowId.length === 0) {
|
|
327
|
+
return baseResult(input, action, {
|
|
328
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
329
|
+
decision: null,
|
|
330
|
+
reason: "--workflow-id must be non-empty",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (!PRE_DISPATCH_ACTIONS.includes(action)) {
|
|
334
|
+
return baseResult(input, "begin", {
|
|
335
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
336
|
+
decision: null,
|
|
337
|
+
reason: `--action must be one of: ${PRE_DISPATCH_ACTIONS.join(", ")}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (action === "complete" &&
|
|
341
|
+
input.status !== undefined &&
|
|
342
|
+
!COMPLETE_STATUSES.includes(input.status)) {
|
|
343
|
+
return baseResult(input, action, {
|
|
344
|
+
exitCode: EXIT_CONFIG_ERROR,
|
|
345
|
+
decision: null,
|
|
346
|
+
reason: `--status must be one of: ${COMPLETE_STATUSES.join(", ")}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (action === "begin")
|
|
350
|
+
return runBegin(input);
|
|
351
|
+
return runComplete(input, action);
|
|
352
|
+
}
|
|
353
|
+
/** Human-readable one-line report for CLI stdout. */
|
|
354
|
+
export function formatPreDispatchReport(result) {
|
|
355
|
+
const unit = `${result.scopeId} / ${result.targetId} / ${result.workflowId}`;
|
|
356
|
+
const decision = result.decision !== null ? result.decision : "n/a";
|
|
357
|
+
const active = result.activeAttemptIds.length > 0 ? result.activeAttemptIds.join(",") : "(none)";
|
|
358
|
+
const lines = [
|
|
359
|
+
`[swarm:pre-dispatch] action=${result.action} exit=${result.exitCode}`,
|
|
360
|
+
` unit: ${unit}`,
|
|
361
|
+
` decision: ${decision}`,
|
|
362
|
+
` reason: ${result.reason}`,
|
|
363
|
+
` active: ${active}`,
|
|
364
|
+
];
|
|
365
|
+
if (result.attempt !== null) {
|
|
366
|
+
lines.push(` attempt: ${result.attempt.attemptId} status=${result.attempt.status}`);
|
|
367
|
+
}
|
|
368
|
+
if (result.exitCode === EXIT_GATE_FAILED && result.decision === "DENY_DUPLICATE_ACTIVE") {
|
|
369
|
+
lines.push(" hint: do not spawn; resume the live leaf, or takeover = cancel then pre-dispatch begin");
|
|
370
|
+
}
|
|
371
|
+
return lines.join("\n");
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=pre-dispatch.js.map
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolveCandidatesLogPath } from "../cache-path.js";
|
|
2
|
-
import type { AuditEntry, CandidatesLog } from "./types.js";
|
|
3
|
-
/** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
|
|
2
|
+
import type { AuditEntry, CandidatesLog } from "./types.js"; /** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
|
|
4
3
|
export declare const AUDIT_LOG_REL_PATH = "xbrief/.triage-cache/candidates.jsonl";
|
|
5
4
|
export { resolveCandidatesLogPath };
|
|
6
5
|
/** Read every well-formed audit-log row in insertion order (#1698 shared reader). */
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { containedWrite } from "../../fs/contained-write.js";
|
|
4
5
|
import { resolveCandidatesLogPath, TRIAGE_CANDIDATES_LOG_REL_PATH } from "../cache-path.js";
|
|
5
6
|
import { CandidatesLogError } from "./errors.js";
|
|
6
|
-
/** Display/back-compat constant; resolution flows through resolveTriageCachePath (#1703). */
|
|
7
7
|
export const AUDIT_LOG_REL_PATH = TRIAGE_CANDIDATES_LOG_REL_PATH;
|
|
8
8
|
export { resolveCandidatesLogPath };
|
|
9
9
|
const VALID_DECISIONS = new Set([
|
|
@@ -108,6 +108,32 @@ function resolveLogPath(projectRoot, override) {
|
|
|
108
108
|
}
|
|
109
109
|
return resolveCandidatesLogPath(projectRoot);
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Containment root for candidates-log product writes (#3245).
|
|
113
|
+
* Prefer project root when the log is nested under it; otherwise parent dir
|
|
114
|
+
* (matches intake candidates-log parity for override paths outside the tree).
|
|
115
|
+
*/
|
|
116
|
+
function containmentRootForLog(projectRoot, logPath) {
|
|
117
|
+
const rootAbs = resolve(projectRoot);
|
|
118
|
+
const logAbs = resolve(logPath);
|
|
119
|
+
const parent = dirname(logAbs);
|
|
120
|
+
mkdirSync(parent, { recursive: true });
|
|
121
|
+
const rel = relative(rootAbs, logAbs);
|
|
122
|
+
if (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel)) {
|
|
123
|
+
return rootAbs;
|
|
124
|
+
}
|
|
125
|
+
return resolve(parent);
|
|
126
|
+
}
|
|
127
|
+
/** #3245 / #2980: product append refuses leaf symlink follow (in-tree or escape). */
|
|
128
|
+
function appendAuditLine(projectRoot, logPath, line) {
|
|
129
|
+
const root = containmentRootForLog(projectRoot, logPath);
|
|
130
|
+
containedWrite({
|
|
131
|
+
root,
|
|
132
|
+
target: logPath,
|
|
133
|
+
data: line.endsWith("\n") ? line : `${line}\n`,
|
|
134
|
+
mode: "append",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
111
137
|
function stableStringify(entry) {
|
|
112
138
|
const sortedKeys = Object.keys(entry).sort();
|
|
113
139
|
const sorted = {};
|
|
@@ -186,8 +212,7 @@ export function createCandidatesLog(defaultProjectRoot) {
|
|
|
186
212
|
append(entry, options) {
|
|
187
213
|
validateEntry(entry);
|
|
188
214
|
const logPath = resolveLogPath(defaultProjectRoot, options?.path);
|
|
189
|
-
|
|
190
|
-
appendFileSync(logPath, `${stableStringify(entry)}\n`, { encoding: "utf8" });
|
|
215
|
+
appendAuditLine(defaultProjectRoot, logPath, `${stableStringify(entry)}\n`);
|
|
191
216
|
return entry.decision_id;
|
|
192
217
|
},
|
|
193
218
|
latestDecision(issueNumber, repo, options) {
|
|
@@ -225,7 +250,13 @@ export function rollbackAuditEntry(decisionId, projectRoot, logPathOverride) {
|
|
|
225
250
|
}
|
|
226
251
|
}
|
|
227
252
|
if (removed) {
|
|
228
|
-
|
|
253
|
+
// #3245: replace rewrite also refuses leaf symlink follow.
|
|
254
|
+
containedWrite({
|
|
255
|
+
root: containmentRootForLog(projectRoot, path),
|
|
256
|
+
target: path,
|
|
257
|
+
data: kept.join(""),
|
|
258
|
+
mode: "replace",
|
|
259
|
+
});
|
|
229
260
|
}
|
|
230
261
|
return removed;
|
|
231
262
|
}
|