@brainervirus/workit-core 1.0.9 → 1.0.11
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/package.json +1 -1
- package/skills/workit-plan/SKILL.md +14 -2
- package/src/core/authority.ts +21 -2
- package/src/core/branch.ts +45 -14
- package/src/core/external-action-effects.ts +205 -9
- package/src/core/external-action.ts +338 -11
- package/src/core/methods.ts +13 -2
- package/src/core/policy-resolver.ts +2 -2
- package/src/core/route-intent.ts +28 -0
- package/src/core/task-context.ts +2 -5
- package/src/core/task-contract.ts +58 -3
- package/src/core/task-engine.ts +60 -45
- package/src/core/task-evaluation.ts +36 -7
- package/src/core/task-store.ts +97 -17
- package/src/core/workers.ts +6 -0
- package/src/core.ts +11 -2
package/package.json
CHANGED
|
@@ -22,9 +22,21 @@ selects requirements; do not wait for a rule that can only exist after assess.
|
|
|
22
22
|
needed, owner, and next action. Keep the plan against the existing system.
|
|
23
23
|
3. If policy separately requires a durable specification, record that behavior
|
|
24
24
|
agreement; otherwise do not invent a spec. A plan without a spec is valid.
|
|
25
|
-
4.
|
|
25
|
+
4. Present before asking: show durable artifacts as a complete digest plus the
|
|
26
|
+
exact path and inline plans as their content. Never ask the user to approve
|
|
27
|
+
something they have not seen, and keep the question to one short scoped
|
|
28
|
+
sentence.
|
|
29
|
+
5. Execute an approved plan continuously: one atomic commit per task after its
|
|
30
|
+
checks pass, no "continue?" prompts. Stop only for a new product decision, a
|
|
31
|
+
failed safety or verification gate, a conflicting concurrent edit, or missing
|
|
32
|
+
authority.
|
|
33
|
+
6. Record the plan's commit list once through the plan-scoped action approval
|
|
34
|
+
(`git.commit` with `plan_steps` and `plan_branch`) so each listed commit runs
|
|
35
|
+
without a new question; an unlisted message, a branch change, or any change to
|
|
36
|
+
the plan needs a fresh exact approval.
|
|
37
|
+
7. Update the shared task progress at meaningful boundaries. Reassess when facts,
|
|
26
38
|
dependencies, or scope change; preserve unresolved blockers and decisions.
|
|
27
|
-
|
|
39
|
+
8. On steering (new instructions mid-task): apply `workit-steer` — park state
|
|
28
40
|
verbatim, classify same-task / new-task / quick-question, handle, re-anchor.
|
|
29
41
|
|
|
30
42
|
Use shared task/progress and evidence operations. Do not create a universal
|
package/src/core/authority.ts
CHANGED
|
@@ -432,7 +432,15 @@ export const verifyDecisionContentAtRoot = (
|
|
|
432
432
|
return failure("invalid_input", "document references require a byte digest");
|
|
433
433
|
const target = path.resolve(root, reference.path);
|
|
434
434
|
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
435
|
-
return failure(
|
|
435
|
+
return failure(
|
|
436
|
+
"invalid_input",
|
|
437
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
438
|
+
{
|
|
439
|
+
fields: [
|
|
440
|
+
{ path: "contentRefs.path", reason: `${reference.path} is outside the checkout` },
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
);
|
|
436
444
|
try {
|
|
437
445
|
const relative = path.relative(root, target);
|
|
438
446
|
let current = root;
|
|
@@ -442,7 +450,18 @@ export const verifyDecisionContentAtRoot = (
|
|
|
442
450
|
const stat = fs.lstatSync(current);
|
|
443
451
|
const real = fs.realpathSync(current);
|
|
444
452
|
if (real !== root && !real.startsWith(`${root}${path.sep}`))
|
|
445
|
-
return failure(
|
|
453
|
+
return failure(
|
|
454
|
+
"invalid_input",
|
|
455
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
456
|
+
{
|
|
457
|
+
fields: [
|
|
458
|
+
{
|
|
459
|
+
path: "contentRefs.path",
|
|
460
|
+
reason: `${reference.path} resolves outside the checkout`,
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
},
|
|
464
|
+
);
|
|
446
465
|
if (stat.isSymbolicLink())
|
|
447
466
|
return failure("invalid_input", "document reference uses a symlink");
|
|
448
467
|
if (current === target && !stat.isFile())
|
package/src/core/branch.ts
CHANGED
|
@@ -268,6 +268,18 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
268
268
|
return fastForwardBase(cwd, base);
|
|
269
269
|
};
|
|
270
270
|
|
|
271
|
+
export type BranchSetupResult =
|
|
272
|
+
| {
|
|
273
|
+
action: "setup";
|
|
274
|
+
ok: true;
|
|
275
|
+
branch: string;
|
|
276
|
+
previous_branch: string;
|
|
277
|
+
stash_ref: string | null;
|
|
278
|
+
manifest: string;
|
|
279
|
+
}
|
|
280
|
+
| { action: "reapply_stash"; ok: true }
|
|
281
|
+
| { error: string; phase: "preflight" | "post_mutation" };
|
|
282
|
+
|
|
271
283
|
// Port of scripts/branch/setup-branch.sh
|
|
272
284
|
export const branchSetup = ({
|
|
273
285
|
action,
|
|
@@ -283,12 +295,13 @@ export const branchSetup = ({
|
|
|
283
295
|
stash?: string;
|
|
284
296
|
workspace_root: string;
|
|
285
297
|
log?: (message: string) => void;
|
|
286
|
-
}) => {
|
|
298
|
+
}): BranchSetupResult => {
|
|
287
299
|
const cwd = path.resolve(workspace_root);
|
|
288
300
|
const exec = (args: string[]): string =>
|
|
289
301
|
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
290
302
|
const current = gitContext(cwd).branch;
|
|
291
|
-
if (!current || current === "unknown")
|
|
303
|
+
if (!current || current === "unknown")
|
|
304
|
+
return { error: "not in a git repository", phase: "preflight" };
|
|
292
305
|
const sdd = sdd_dir ?? "docs";
|
|
293
306
|
const manifestPath = path.isAbsolute(sdd)
|
|
294
307
|
? path.join(sdd, "manifest.json")
|
|
@@ -297,7 +310,6 @@ export const branchSetup = ({
|
|
|
297
310
|
const dir = path.dirname(manifestPath);
|
|
298
311
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
299
312
|
};
|
|
300
|
-
ensureManifestDir();
|
|
301
313
|
const readManifest = (): Record<string, unknown> => {
|
|
302
314
|
try {
|
|
303
315
|
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
@@ -315,13 +327,16 @@ export const branchSetup = ({
|
|
|
315
327
|
if (action === "reapply_stash") {
|
|
316
328
|
const manifest = readManifest();
|
|
317
329
|
const ref = manifest.stash_ref;
|
|
318
|
-
if (!ref) return { error: "no stash_ref in manifest" };
|
|
330
|
+
if (!ref) return { error: "no stash_ref in manifest", phase: "preflight" };
|
|
319
331
|
journal(`pre-pop: ${String(ref)}`);
|
|
320
332
|
try {
|
|
321
333
|
exec(["stash", "pop", String(ref)]);
|
|
322
334
|
} catch (error) {
|
|
323
335
|
journal("pop: failed");
|
|
324
|
-
return {
|
|
336
|
+
return {
|
|
337
|
+
error: error instanceof Error ? error.message : "stash pop failed",
|
|
338
|
+
phase: "post_mutation",
|
|
339
|
+
};
|
|
325
340
|
}
|
|
326
341
|
journal("pop: ok");
|
|
327
342
|
delete manifest.stash_ref;
|
|
@@ -331,10 +346,17 @@ export const branchSetup = ({
|
|
|
331
346
|
}
|
|
332
347
|
|
|
333
348
|
const target = target_branch ?? "";
|
|
334
|
-
if (!target)
|
|
335
|
-
|
|
349
|
+
if (!target)
|
|
350
|
+
return {
|
|
351
|
+
error: "target_branch (the working branch to create or switch to) is required for setup",
|
|
352
|
+
phase: "preflight",
|
|
353
|
+
};
|
|
354
|
+
if (isProtected(cwd, target)) return { error: `protected branch ${target}`, phase: "preflight" };
|
|
336
355
|
if (!allowedBranch(cwd, target))
|
|
337
|
-
return {
|
|
356
|
+
return {
|
|
357
|
+
error: `target branch ${target} is not allowed by the branch policy`,
|
|
358
|
+
phase: "preflight",
|
|
359
|
+
};
|
|
338
360
|
|
|
339
361
|
// CA-02: resolve the base up front so an unresolvable base fails before
|
|
340
362
|
// any mutation. The origin/<base> validation runs after the stash gate
|
|
@@ -348,7 +370,7 @@ export const branchSetup = ({
|
|
|
348
370
|
}
|
|
349
371
|
if (!targetExists) {
|
|
350
372
|
const baseResolved = baseBranch(cwd);
|
|
351
|
-
if ("error" in baseResolved) return { error: baseResolved.error };
|
|
373
|
+
if ("error" in baseResolved) return { error: baseResolved.error, phase: "preflight" };
|
|
352
374
|
base = baseResolved.base;
|
|
353
375
|
}
|
|
354
376
|
journal(`entry: current=${current} target=${target} base=${base ?? "-"}`);
|
|
@@ -356,7 +378,10 @@ export const branchSetup = ({
|
|
|
356
378
|
let stash_ref: string | undefined;
|
|
357
379
|
// Best-effort restore; if the pop itself fails, the caller's error gains a
|
|
358
380
|
// suffix pointing at the stash so stranded work stays discoverable.
|
|
359
|
-
const failAfterStash = (
|
|
381
|
+
const failAfterStash = (
|
|
382
|
+
message: string,
|
|
383
|
+
phase: "preflight" | "post_mutation" = "post_mutation",
|
|
384
|
+
): { error: string; phase: "preflight" | "post_mutation" } => {
|
|
360
385
|
let suffix = "";
|
|
361
386
|
if (stash_ref) {
|
|
362
387
|
journal(`pre-pop: ${stash_ref}`);
|
|
@@ -369,7 +394,7 @@ export const branchSetup = ({
|
|
|
369
394
|
suffix = " (changes preserved in stash)";
|
|
370
395
|
}
|
|
371
396
|
}
|
|
372
|
-
return { error: `${message}${suffix}
|
|
397
|
+
return { error: `${message}${suffix}`, phase: stash_ref ? "post_mutation" : phase };
|
|
373
398
|
};
|
|
374
399
|
if (current !== target) {
|
|
375
400
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
@@ -377,6 +402,7 @@ export const branchSetup = ({
|
|
|
377
402
|
return {
|
|
378
403
|
error:
|
|
379
404
|
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
405
|
+
phase: "preflight",
|
|
380
406
|
};
|
|
381
407
|
}
|
|
382
408
|
// CA-02: validate origin/<base> before ANY mutation (no snapshot, no
|
|
@@ -386,14 +412,18 @@ export const branchSetup = ({
|
|
|
386
412
|
let validatedBase: string | undefined;
|
|
387
413
|
if (!targetExists && base !== undefined) {
|
|
388
414
|
const ready = originBaseReady(cwd, base);
|
|
389
|
-
if (!ready.ok)
|
|
415
|
+
if (!ready.ok)
|
|
416
|
+
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
390
417
|
validatedBase = base;
|
|
391
418
|
}
|
|
392
419
|
if (dirty) {
|
|
393
420
|
try {
|
|
394
421
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
395
422
|
} catch (error) {
|
|
396
|
-
return {
|
|
423
|
+
return {
|
|
424
|
+
error: error instanceof Error ? error.message : "stash push failed",
|
|
425
|
+
phase: "post_mutation",
|
|
426
|
+
};
|
|
397
427
|
}
|
|
398
428
|
stash_ref = "stash@{0}";
|
|
399
429
|
journal(`stash push: ${stash_ref}`);
|
|
@@ -406,13 +436,14 @@ export const branchSetup = ({
|
|
|
406
436
|
if (/worktree/i.test(message)) {
|
|
407
437
|
return failAfterStash(
|
|
408
438
|
`branch ${target} is locked by an existing git worktree — remove it first (we do not use worktrees)`,
|
|
439
|
+
"preflight",
|
|
409
440
|
);
|
|
410
441
|
}
|
|
411
442
|
try {
|
|
412
443
|
let effectiveBase = base;
|
|
413
444
|
if (effectiveBase === undefined) {
|
|
414
445
|
const lateResolved = baseBranch(cwd);
|
|
415
|
-
if ("error" in lateResolved) return failAfterStash(lateResolved.error);
|
|
446
|
+
if ("error" in lateResolved) return failAfterStash(lateResolved.error, "preflight");
|
|
416
447
|
effectiveBase = lateResolved.base;
|
|
417
448
|
}
|
|
418
449
|
// Pre-validated above when the target was missing: only the
|
|
@@ -236,6 +236,125 @@ const safePath = (root: string, value: string): string | null => {
|
|
|
236
236
|
}
|
|
237
237
|
};
|
|
238
238
|
|
|
239
|
+
/** Read-only baseline bound into a branch-setup proposal: the exact remote
|
|
240
|
+
* commit the base currently points at, without moving any local ref. */
|
|
241
|
+
const branchCreationBaseline = (
|
|
242
|
+
root: string,
|
|
243
|
+
target: string,
|
|
244
|
+
base: string,
|
|
245
|
+
): { base_branch: string; target_exists: boolean; remote_base: string | null } => {
|
|
246
|
+
const exists =
|
|
247
|
+
gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
|
|
248
|
+
if (exists) return { base_branch: base, target_exists: true, remote_base: null };
|
|
249
|
+
const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
|
|
250
|
+
const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
|
|
251
|
+
return {
|
|
252
|
+
base_branch: base,
|
|
253
|
+
target_exists: false,
|
|
254
|
+
remote_base: /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null,
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const shortSha = (value: unknown): string =>
|
|
259
|
+
typeof value === "string" && value ? value.slice(0, 8) : "unknown";
|
|
260
|
+
|
|
261
|
+
const isProtectedBranch = (root: string, branch: string): boolean =>
|
|
262
|
+
resolveBranchPolicyFor(root).protected.has(branch.toLowerCase());
|
|
263
|
+
|
|
264
|
+
/** Concise, user-facing approval text for one resolved action. The exact
|
|
265
|
+
* descriptor stays machine-facing; this is what the native question shows. */
|
|
266
|
+
export const actionProposalQuestion = (
|
|
267
|
+
request: ExternalActionRequest,
|
|
268
|
+
descriptorPayload: unknown,
|
|
269
|
+
): { presented: string; approvedText: string } => {
|
|
270
|
+
const payload = (descriptorPayload ?? {}) as Record<string, unknown>;
|
|
271
|
+
const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
|
|
272
|
+
const payloadTarget = String(payload.target_branch ?? "");
|
|
273
|
+
if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
|
|
274
|
+
const steps = payload.plan_steps as string[];
|
|
275
|
+
return {
|
|
276
|
+
presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
|
|
277
|
+
approvedText: `Approve the ${steps.length} listed plan commits.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
switch (request.operation) {
|
|
281
|
+
case "git.branch_setup": {
|
|
282
|
+
if (request.payload.action === "reapply_stash")
|
|
283
|
+
return {
|
|
284
|
+
presented:
|
|
285
|
+
"Workit decision: action — Reapply the pre-checkout stash recorded in the branch manifest?",
|
|
286
|
+
approvedText: "Reapply the recorded stash.",
|
|
287
|
+
};
|
|
288
|
+
const target = String(payload.target_branch ?? "");
|
|
289
|
+
// A dirty tree binds stash: the approval carries the stash through,
|
|
290
|
+
// so the question names it instead of failing later at preflight.
|
|
291
|
+
const stashes = payload.stash === "yes" && resolved.dirty === true;
|
|
292
|
+
if (resolved.target_exists === true)
|
|
293
|
+
return stashes
|
|
294
|
+
? {
|
|
295
|
+
presented: `Workit decision: action — Stash the dirty working tree and switch to branch \`${target}\`?`,
|
|
296
|
+
approvedText: `Stash and switch to \`${target}\`.`,
|
|
297
|
+
}
|
|
298
|
+
: {
|
|
299
|
+
presented: `Workit decision: action — Switch to branch \`${target}\`?`,
|
|
300
|
+
approvedText: `Switch to \`${target}\`.`,
|
|
301
|
+
};
|
|
302
|
+
const base = String(resolved.base_branch ?? "the base branch");
|
|
303
|
+
return stashes
|
|
304
|
+
? {
|
|
305
|
+
presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
306
|
+
approvedText: `Stash and create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
307
|
+
}
|
|
308
|
+
: {
|
|
309
|
+
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
310
|
+
approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
case "git.commit": {
|
|
314
|
+
const paths = Array.isArray(resolved.paths) ? (resolved.paths as string[]) : [];
|
|
315
|
+
return {
|
|
316
|
+
presented: `Workit decision: action — Commit ${paths.length} staged file(s) as "${String(request.payload.message)}"?`,
|
|
317
|
+
approvedText: `Commit as "${String(request.payload.message)}".`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
case "git.push":
|
|
321
|
+
return {
|
|
322
|
+
presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to origin?`,
|
|
323
|
+
approvedText: `Push \`${String(payload.branch ?? "")}\` to origin.`,
|
|
324
|
+
};
|
|
325
|
+
case "hosting.pull_request":
|
|
326
|
+
return {
|
|
327
|
+
presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}\` to \`${payloadTarget}\` titled "${String(request.payload.title)}"?`,
|
|
328
|
+
approvedText: `Open the PR: ${String(resolved.source_branch ?? "")} → ${payloadTarget}, "${String(request.payload.title)}".`,
|
|
329
|
+
};
|
|
330
|
+
case "changelog.apply":
|
|
331
|
+
return {
|
|
332
|
+
presented: `Workit decision: action — Apply changelog changes at \`${String(payload.path ?? "CHANGELOG.md")}\`?`,
|
|
333
|
+
approvedText: `Apply changelog changes at ${String(payload.path ?? "CHANGELOG.md")}.`,
|
|
334
|
+
};
|
|
335
|
+
case "youtrack.update":
|
|
336
|
+
return {
|
|
337
|
+
presented: `Workit decision: action — Update YouTrack issue ${String(request.payload.issueId)}${request.payload.minutes ? ` and log ${String(request.payload.minutes)}m` : ""}?`,
|
|
338
|
+
approvedText: `Update YouTrack issue ${String(request.payload.issueId)}.`,
|
|
339
|
+
};
|
|
340
|
+
case "youtrack.time":
|
|
341
|
+
return {
|
|
342
|
+
presented: `Workit decision: action — Log ${String(request.payload.minutes)}m to YouTrack issue ${String(request.payload.issueId)}?`,
|
|
343
|
+
approvedText: `Log time to YouTrack issue ${String(request.payload.issueId)}.`,
|
|
344
|
+
};
|
|
345
|
+
case "youtrack.meeting":
|
|
346
|
+
return {
|
|
347
|
+
presented: `Workit decision: action — Log meeting time to YouTrack issue ${String(request.payload.issueId)}?`,
|
|
348
|
+
approvedText: `Log meeting time to YouTrack issue ${String(request.payload.issueId)}.`,
|
|
349
|
+
};
|
|
350
|
+
default:
|
|
351
|
+
return {
|
|
352
|
+
presented: `Workit decision: action — Approve ${request.operation}?`,
|
|
353
|
+
approvedText: `Approve ${request.operation}.`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
239
358
|
export type ResolvedExternalAction = {
|
|
240
359
|
request: ExternalActionRequest;
|
|
241
360
|
descriptorPayload: unknown;
|
|
@@ -697,23 +816,41 @@ export const resolveExternalActionRequest = (
|
|
|
697
816
|
case "git.branch_setup": {
|
|
698
817
|
const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
|
|
699
818
|
if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
|
|
819
|
+
const action = request.payload.action ?? "setup";
|
|
700
820
|
const policy = resolveBranchPolicyFor(root);
|
|
701
|
-
const target_branch = request.payload.target_branch
|
|
821
|
+
const target_branch = request.payload.target_branch;
|
|
822
|
+
if (action !== "reapply_stash" && !target_branch)
|
|
823
|
+
return failure(
|
|
824
|
+
"invalid_input",
|
|
825
|
+
"target_branch (the working branch to create or switch to) is required for setup",
|
|
826
|
+
{
|
|
827
|
+
outcome: "not_started",
|
|
828
|
+
fields: [{ path: "target_branch", reason: "required for action=setup" }],
|
|
829
|
+
},
|
|
830
|
+
);
|
|
702
831
|
const head = gitValue(root, ["rev-parse", "HEAD"]);
|
|
703
832
|
if (!head) return failure("storage_error", "current Git commit could not be resolved");
|
|
833
|
+
const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
|
|
834
|
+
const creation =
|
|
835
|
+
action === "reapply_stash" || !target_branch
|
|
836
|
+
? { base_branch: policy.defaultTargetBranch }
|
|
837
|
+
: branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
|
|
704
838
|
const normalized = {
|
|
705
839
|
operation: request.operation,
|
|
706
840
|
payload: {
|
|
707
841
|
...request.payload,
|
|
708
|
-
action
|
|
842
|
+
action,
|
|
709
843
|
sdd_dir,
|
|
710
844
|
stash: request.payload.stash ?? "no",
|
|
711
|
-
target_branch,
|
|
845
|
+
...(target_branch ? { target_branch } : {}),
|
|
712
846
|
},
|
|
713
847
|
} as ExternalActionRequest;
|
|
714
848
|
return success(null, null, {
|
|
715
849
|
request: normalized,
|
|
716
|
-
descriptorPayload: {
|
|
850
|
+
descriptorPayload: {
|
|
851
|
+
...normalized.payload,
|
|
852
|
+
resolved: { head, dirty, ...creation },
|
|
853
|
+
},
|
|
717
854
|
});
|
|
718
855
|
}
|
|
719
856
|
case "git.push": {
|
|
@@ -729,7 +866,46 @@ export const resolveExternalActionRequest = (
|
|
|
729
866
|
: failure("invalid_input", "git push requires a resolvable current branch");
|
|
730
867
|
}
|
|
731
868
|
case "git.commit": {
|
|
869
|
+
const planSteps = request.payload.plan_steps;
|
|
870
|
+
const planBranch = request.payload.plan_branch;
|
|
732
871
|
const head = gitValue(root, ["rev-parse", "HEAD"]);
|
|
872
|
+
if (!head) return failure("storage_error", "Git state could not be resolved");
|
|
873
|
+
const branch = gitValue(root, ["branch", "--show-current"]);
|
|
874
|
+
if (planSteps !== undefined || planBranch !== undefined) {
|
|
875
|
+
if (
|
|
876
|
+
!Array.isArray(planSteps) ||
|
|
877
|
+
planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
|
|
878
|
+
new Set(planSteps).size !== planSteps.length ||
|
|
879
|
+
planSteps.length === 0 ||
|
|
880
|
+
planSteps.length > 32
|
|
881
|
+
)
|
|
882
|
+
return failure(
|
|
883
|
+
"invalid_input",
|
|
884
|
+
"plan_steps must be 1-32 unique non-empty commit messages",
|
|
885
|
+
{
|
|
886
|
+
outcome: "not_started",
|
|
887
|
+
fields: [{ path: "plan_steps", reason: "unique strings" }],
|
|
888
|
+
},
|
|
889
|
+
);
|
|
890
|
+
if (typeof planBranch !== "string" || !planBranch.trim() || planBranch !== branch)
|
|
891
|
+
return failure("invalid_input", "plan_branch must name the current working branch", {
|
|
892
|
+
outcome: "not_started",
|
|
893
|
+
fields: [{ path: "plan_branch", reason: "current branch" }],
|
|
894
|
+
});
|
|
895
|
+
if (isProtectedBranch(root, planBranch))
|
|
896
|
+
return failure("invalid_input", "plan_branch must not be a protected branch", {
|
|
897
|
+
outcome: "not_started",
|
|
898
|
+
fields: [{ path: "plan_branch", reason: "protected branch" }],
|
|
899
|
+
});
|
|
900
|
+
return success(null, null, {
|
|
901
|
+
request,
|
|
902
|
+
descriptorPayload: {
|
|
903
|
+
plan_steps: planSteps,
|
|
904
|
+
plan_branch: planBranch,
|
|
905
|
+
resolved: { head, branch, steps: planSteps },
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
}
|
|
733
909
|
const staged = gitRaw(root, [
|
|
734
910
|
"diff",
|
|
735
911
|
"--cached",
|
|
@@ -740,7 +916,13 @@ export const resolveExternalActionRequest = (
|
|
|
740
916
|
"HEAD",
|
|
741
917
|
]);
|
|
742
918
|
const paths = stagedPaths(root);
|
|
743
|
-
|
|
919
|
+
const message = request.payload.message;
|
|
920
|
+
if (typeof message !== "string" || !message)
|
|
921
|
+
return failure("invalid_input", "git.commit requires a commit message", {
|
|
922
|
+
outcome: "not_started",
|
|
923
|
+
fields: [{ path: "message", reason: "required unless plan_steps is provided" }],
|
|
924
|
+
});
|
|
925
|
+
if (staged === null || paths === null)
|
|
744
926
|
return failure("storage_error", "Git state could not be resolved");
|
|
745
927
|
let policy: { preset: string; pattern?: string };
|
|
746
928
|
try {
|
|
@@ -757,7 +939,7 @@ export const resolveExternalActionRequest = (
|
|
|
757
939
|
const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
|
|
758
940
|
flavor = detected.flavor ?? "conventional";
|
|
759
941
|
}
|
|
760
|
-
if (!matchCommitFlavor(
|
|
942
|
+
if (!matchCommitFlavor(message, flavor as CommitFlavor, policy.pattern))
|
|
761
943
|
return failure(
|
|
762
944
|
"invalid_input",
|
|
763
945
|
`Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
|
|
@@ -766,7 +948,7 @@ export const resolveExternalActionRequest = (
|
|
|
766
948
|
request,
|
|
767
949
|
descriptorPayload: {
|
|
768
950
|
...request.payload,
|
|
769
|
-
resolved: { head, staged: sha256(staged), paths },
|
|
951
|
+
resolved: { head, branch, staged: sha256(staged), paths },
|
|
770
952
|
},
|
|
771
953
|
});
|
|
772
954
|
}
|
|
@@ -985,10 +1167,24 @@ export const executeConcreteExternalAction = async (
|
|
|
985
1167
|
sdd_dir: request.payload.sdd_dir ?? "docs",
|
|
986
1168
|
workspace_root: root,
|
|
987
1169
|
});
|
|
988
|
-
|
|
1170
|
+
if ("error" in result) {
|
|
1171
|
+
if (result.phase === "preflight")
|
|
1172
|
+
return failure("invalid_input", result.error, {
|
|
1173
|
+
outcome: "not_started",
|
|
1174
|
+
operation: request.operation,
|
|
1175
|
+
});
|
|
1176
|
+
return unknown(request.operation);
|
|
1177
|
+
}
|
|
1178
|
+
return success(null, null, result);
|
|
989
1179
|
}
|
|
990
1180
|
case "git.commit": {
|
|
991
|
-
const
|
|
1181
|
+
const message = request.payload.message;
|
|
1182
|
+
if (typeof message !== "string" || !message)
|
|
1183
|
+
return failure("invalid_input", "git.commit requires a commit message", {
|
|
1184
|
+
outcome: "not_started",
|
|
1185
|
+
fields: [{ path: "message", reason: "required" }],
|
|
1186
|
+
});
|
|
1187
|
+
const result = run(root, ["commit", "-m", message]);
|
|
992
1188
|
return result.exitCode === 0
|
|
993
1189
|
? success(null, null, { stdout: result.stdout.trim() })
|
|
994
1190
|
: unknown(request.operation);
|