@goodtek/vibeops 2.3.0 → 2.4.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/CHANGELOG.md +20 -0
- package/README.md +20 -9
- package/dist/cli.js +22 -5
- package/dist/commands/task-reship.js +17 -171
- package/dist/commands/task-ship.js +260 -46
- package/dist/lib/inquirer-helpers.js +4 -0
- package/dist/lib/pr-create.js +10 -2
- package/dist/lib/resolve-task.js +31 -1
- package/dist/lib/task-commit-msg.js +80 -0
- package/dist/lib/task-effective-status.js +15 -7
- package/dist/lib/task-git-commit.js +24 -0
- package/dist/lib/task-new-cycle.js +186 -0
- package/dist/lib/task-ship-state.js +39 -0
- package/package.json +3 -2
- package/templates/clients/claude/CLAUDE.md +1 -1
- package/templates/clients/cursor/.cursor/rules/02-git-safety.mdc +1 -1
- package/templates/clients/cursor/.cursor/rules/03-docs-before-ship.mdc +1 -1
- package/templates/core/AGENTS.md +10 -7
- package/templates/skills-shared/implement-task/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.4.0 - 2026-06-12
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`vibeops task ship` is now state-aware.** It detects the TASK's PR/MR state and branches:
|
|
12
|
+
1. **No PR** → push + open a new PR (first submit, Status → Shipped).
|
|
13
|
+
2. **Open PR (unmerged)** → commit + push the **same** branch to update the existing PR (no new PR); CI re-runs. This replaces the manual `git add/commit/push` loop.
|
|
14
|
+
3. **Merged PR** → start a **new** PR cycle (former `reship`), guarded by an interactive confirm (`This starts a NEW PR cycle after merge. Continue? (y/N)`) or `--new-cycle` in non-interactive mode.
|
|
15
|
+
- Every path prints a one-line summary: `Created PR #<n> → <url>`, `Updated existing PR #<n> (pushed <sha>) — CI re-running`, `Started new PR cycle → PR #<n>`, or `Nothing to ship (working tree clean, PR #<n> up to date)`.
|
|
16
|
+
- Commit messages are auto-scoped with the TASK id (`feat(task-001): …`). `ship` refuses when HEAD is not the TASK branch.
|
|
17
|
+
- PR/MR state resolution is unified in a single source (`resolveTaskMergeRequestLifecycle`).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`vibeops task ship`** flags: `-m/--message <msg>` (TASK id auto-prefixed; LLM-drafted or prompted when omitted), `--new-cycle` (alias `--reship`), `--no-commit` (push committed history only), `--non-interactive`.
|
|
22
|
+
|
|
23
|
+
### Deprecated
|
|
24
|
+
|
|
25
|
+
- **`vibeops task reship`** is now a thin alias for `vibeops task ship --new-cycle` and prints a deprecation notice.
|
|
26
|
+
|
|
7
27
|
## 2.3.0 - 2026-06-09
|
|
8
28
|
|
|
9
29
|
### Added
|
package/README.md
CHANGED
|
@@ -14,8 +14,8 @@ VibeOps bootstraps an **agent-friendly repo**, starts numbered **TASK** files on
|
|
|
14
14
|
| `vibeops init` | Core docs + agent packs (cursor / claude / codex) |
|
|
15
15
|
| `vibeops task add` | New `TASK-NNN` file + task branch |
|
|
16
16
|
| `vibeops task del` | Cancel TASK before merge (md + branch + close open MR) |
|
|
17
|
-
| `vibeops task ship` |
|
|
18
|
-
| `vibeops task reship` |
|
|
17
|
+
| `vibeops task ship` | State-aware submit: open a new PR · update the open PR · start a new PR cycle after merge (Status → **Shipped**) |
|
|
18
|
+
| `vibeops task reship` | **Deprecated** alias for `task ship --new-cycle` |
|
|
19
19
|
| `vibeops task merge` | Merge TASK MR/PR into integration branch (default: squash) |
|
|
20
20
|
| `vibeops task sync` | After merge: ff-only integration pull, delete task branches (no TASK md edits) |
|
|
21
21
|
| `vibeops task release` | Release PR: integration → production (GitFlow) |
|
|
@@ -56,13 +56,24 @@ vibeops task add
|
|
|
56
56
|
vibeops task ship
|
|
57
57
|
vibeops task merge
|
|
58
58
|
vibeops pull
|
|
59
|
-
#
|
|
60
|
-
vibeops task
|
|
59
|
+
# Same TASK, before merge — edit, then just re-run ship (updates the open PR):
|
|
60
|
+
vibeops task ship -m "address review"
|
|
61
|
+
# Same TASK, after merge — start a new PR cycle:
|
|
62
|
+
vibeops task ship --new-cycle
|
|
61
63
|
vibeops task merge
|
|
62
64
|
vibeops task sync
|
|
63
65
|
vibeops task add
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
`ship` detects the TASK's PR state and does the right thing:
|
|
69
|
+
|
|
70
|
+
| State | `ship` does | Output |
|
|
71
|
+
|-------|-------------|--------|
|
|
72
|
+
| No PR yet | push + open a new PR | `Created PR #<n> → <url>` |
|
|
73
|
+
| PR open (unmerged) | commit + push the **same** branch (no new PR), CI re-runs | `Updated existing PR #<n> (pushed <sha>) — CI re-running` |
|
|
74
|
+
| PR merged | start a **new** PR cycle (needs confirm or `--new-cycle`) | `Started new PR cycle → PR #<n>` |
|
|
75
|
+
| No change | no-op | `Nothing to ship (working tree clean, PR #<n> up to date)` |
|
|
76
|
+
|
|
66
77
|
Occasionally (GitFlow release to production):
|
|
67
78
|
|
|
68
79
|
```bash
|
|
@@ -73,7 +84,7 @@ Only one TASK **In Progress** at a time (`task add` blocks otherwise). **Shipped
|
|
|
73
84
|
|
|
74
85
|
## LLM (optional)
|
|
75
86
|
|
|
76
|
-
For **`task add`** / **`task ship`**
|
|
87
|
+
For **`task add`** / **`task ship`** only (not for coding in the IDE). When `-m` is omitted, `ship` uses a connected provider to draft the commit subject:
|
|
77
88
|
|
|
78
89
|
```bash
|
|
79
90
|
vibeops llm connect
|
|
@@ -99,8 +110,8 @@ pnpm install && pnpm build && pnpm smoke
|
|
|
99
110
|
- **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--allow-no-remote`, `--cwd`
|
|
100
111
|
- **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
|
|
101
112
|
- **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
|
|
102
|
-
- **`task ship`**: `--dry-run`, `--no-pr`
|
|
103
|
-
- **`task reship
|
|
113
|
+
- **`task ship`**: `-m/--message`, `--new-cycle` (alias `--reship`), `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
|
|
114
|
+
- **`task reship`** (deprecated): `--dry-run`, `--no-pr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`, `--allow-open-mr` → delegates to `task ship --new-cycle`
|
|
104
115
|
- **`task merge`**: `--dry-run`, `--merge`, `--rebase`
|
|
105
116
|
- **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
|
|
106
117
|
- **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
|
|
@@ -111,8 +122,8 @@ pnpm install && pnpm build && pnpm smoke
|
|
|
111
122
|
|
|
112
123
|
- **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`).
|
|
113
124
|
- **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
|
|
114
|
-
- **`task ship
|
|
115
|
-
- **`task reship
|
|
125
|
+
- **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
|
|
126
|
+
- **`task reship`** *(deprecated)*: alias for `task ship --new-cycle`.
|
|
116
127
|
- **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
|
|
117
128
|
- **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
|
|
118
129
|
- **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
|
package/dist/cli.js
CHANGED
|
@@ -96,20 +96,37 @@ task
|
|
|
96
96
|
});
|
|
97
97
|
task
|
|
98
98
|
.command("ship [taskRef]")
|
|
99
|
-
.description("
|
|
99
|
+
.description("State-aware submit: new PR · update open PR · new PR cycle after merge (Status → Shipped)")
|
|
100
100
|
.option("--dry-run", "Plan only")
|
|
101
|
+
.option("-m, --message <msg>", "Commit message (TASK id auto-prefixed; LLM/prompt when omitted)")
|
|
102
|
+
.option("--new-cycle", "Allow a new PR cycle after merge without prompting (alias: --reship)")
|
|
103
|
+
.option("--reship", "Alias for --new-cycle")
|
|
104
|
+
.option("--no-commit", "Push already-committed changes only (skip staging/commit)")
|
|
101
105
|
.option("--no-pr", "Push only; skip creating MR/PR")
|
|
106
|
+
.option("--non-interactive", "CI: never prompt")
|
|
107
|
+
.option("--no-integrate", "New PR cycle: skip merging integration into the task branch")
|
|
108
|
+
.option("--recreate-branch", "New PR cycle: recreate task branch from integration")
|
|
109
|
+
.option("--skip-llm", "New PR cycle: do not run LLM for Result / Test Result")
|
|
110
|
+
.option("--allow-open-mr", "Update the open MR/PR with new commits instead of a new cycle")
|
|
102
111
|
.option("--cwd <path>", "Target directory")
|
|
103
112
|
.action(async (taskRef, opts) => {
|
|
104
113
|
await taskShipCommand(taskRef, {
|
|
105
114
|
dryRun: Boolean(opts.dryRun),
|
|
106
|
-
|
|
115
|
+
message: opts.message,
|
|
116
|
+
newCycle: Boolean(opts.newCycle) || Boolean(opts.reship),
|
|
117
|
+
noCommit: opts.commit === false,
|
|
118
|
+
noPr: opts.pr === false,
|
|
119
|
+
nonInteractive: Boolean(opts.nonInteractive),
|
|
120
|
+
noIntegrate: opts.integrate === false,
|
|
121
|
+
recreateBranch: Boolean(opts.recreateBranch),
|
|
122
|
+
skipLlm: Boolean(opts.skipLlm),
|
|
123
|
+
allowOpenMr: Boolean(opts.allowOpenMr),
|
|
107
124
|
cwd: opts.cwd,
|
|
108
125
|
});
|
|
109
126
|
});
|
|
110
127
|
task
|
|
111
128
|
.command("reship [taskRef]")
|
|
112
|
-
.description("
|
|
129
|
+
.description("Deprecated alias for `task ship --new-cycle` (start a new PR cycle after merge)")
|
|
113
130
|
.option("--dry-run", "Plan only")
|
|
114
131
|
.option("--no-pr", "Push only; skip creating MR/PR")
|
|
115
132
|
.option("--no-integrate", "Skip merging integration branch into task branch")
|
|
@@ -120,8 +137,8 @@ task
|
|
|
120
137
|
.action(async (taskRef, opts) => {
|
|
121
138
|
await taskReshipCommand(taskRef, {
|
|
122
139
|
dryRun: Boolean(opts.dryRun),
|
|
123
|
-
noPr:
|
|
124
|
-
noIntegrate:
|
|
140
|
+
noPr: opts.pr === false,
|
|
141
|
+
noIntegrate: opts.integrate === false,
|
|
125
142
|
recreateBranch: Boolean(opts.recreateBranch),
|
|
126
143
|
skipLlm: Boolean(opts.skipLlm),
|
|
127
144
|
allowOpenMr: Boolean(opts.allowOpenMr),
|
|
@@ -1,175 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
8
|
-
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
9
|
-
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "../lib/task-reship.js";
|
|
10
|
-
import { hasNonEmptySection, parseTaskFilename, readGitContext, } from "../lib/task.js";
|
|
11
|
-
import { listWorkingTreeRelPaths } from "../lib/git.js";
|
|
12
|
-
async function commitReshipMetadata(opts) {
|
|
13
|
-
if (opts.dryRun) {
|
|
14
|
-
log.info(dim(" would commit reship metadata (Status stays Shipped)"));
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "reship metadata"), false);
|
|
18
|
-
if (!committed) {
|
|
19
|
-
log.info(dim("No reship metadata commit (Git Context already on disk)."));
|
|
20
|
-
}
|
|
21
|
-
}
|
|
1
|
+
import { dim, log, yellow } from "../lib/logger.js";
|
|
2
|
+
import { taskShipCommand } from "./task-ship.js";
|
|
3
|
+
/**
|
|
4
|
+
* @deprecated `reship` is now `vibeops task ship --new-cycle`.
|
|
5
|
+
* Kept as a thin alias for backward compatibility.
|
|
6
|
+
*/
|
|
22
7
|
export async function taskReshipCommand(taskRef, options = {}) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const dryRun = options.dryRun === true;
|
|
26
|
-
const target = await resolveReshipTarget(paths, cwd, taskRef);
|
|
27
|
-
if (target === null) {
|
|
28
|
-
process.exitCode = 1;
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
const { taskId, taskFile, meta, gitCtx: initialCtx } = target;
|
|
32
|
-
const relFile = relPath(cwd, taskFile);
|
|
33
|
-
log.info(bold(`vibeops task reship ${taskId}`));
|
|
34
|
-
log.info(` ${dim("file")} ${relFile}`);
|
|
35
|
-
log.info(` ${dim("branch")} ${initialCtx.taskBranch}`);
|
|
36
|
-
log.info(` ${dim("status")} Shipped (unchanged)`);
|
|
37
|
-
log.blank();
|
|
38
|
-
const gitCfg = dryRun
|
|
39
|
-
? await loadGitConfigForReship(cwd).catch(() => null)
|
|
40
|
-
: await loadGitConfigForReship(cwd);
|
|
41
|
-
if (!dryRun && gitCfg === null) {
|
|
42
|
-
process.exitCode = 1;
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const integrationBranch = gitCfg?.integrationBranch ?? "develop";
|
|
46
|
-
const remote = gitCfg?.remote ?? "origin";
|
|
47
|
-
const updateOpenMr = options.allowOpenMr === true;
|
|
48
|
-
if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
|
|
49
|
-
process.exitCode = 1;
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const branchOk = await ensureTaskBranchForReship({
|
|
53
|
-
cwd,
|
|
54
|
-
taskFile,
|
|
55
|
-
gitCtx: initialCtx,
|
|
56
|
-
integrationBranch,
|
|
57
|
-
remote,
|
|
58
|
-
recreateBranch: options.recreateBranch === true,
|
|
59
|
-
dryRun,
|
|
60
|
-
});
|
|
61
|
-
if (!branchOk) {
|
|
62
|
-
process.exitCode = 1;
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (options.noIntegrate !== true) {
|
|
66
|
-
const integrated = await integrateIntegrationIntoTaskBranch({
|
|
67
|
-
cwd,
|
|
68
|
-
integrationBranch,
|
|
69
|
-
remote,
|
|
70
|
-
dryRun,
|
|
71
|
-
});
|
|
72
|
-
if (!integrated) {
|
|
73
|
-
process.exitCode = 1;
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
log.info(dim("Skipped integration merge (--no-integrate)."));
|
|
79
|
-
}
|
|
80
|
-
const body = await readText(taskFile);
|
|
81
|
-
let resultOk = hasNonEmptySection(body, "Result");
|
|
82
|
-
let testOk = hasNonEmptySection(body, "Test Result");
|
|
83
|
-
if (!options.skipLlm && !dryRun) {
|
|
84
|
-
log.step("LLM — follow-up Result / Test Result");
|
|
85
|
-
const patch = await llmCompleteTaskReship({
|
|
86
|
-
cwd,
|
|
87
|
-
taskId,
|
|
88
|
-
taskTitle: meta.title,
|
|
89
|
-
taskBody: body,
|
|
90
|
-
taskFileRel: relFile,
|
|
91
|
-
});
|
|
92
|
-
if (patch !== null) {
|
|
93
|
-
await writeTaskResultSections(taskFile, patch.result, patch.testResult);
|
|
94
|
-
log.ok(`Result / Test Result updated (${patch.provider})`);
|
|
95
|
-
const memory = await applyTaskShipMemory(cwd, patch);
|
|
96
|
-
for (const p of memory.updated)
|
|
97
|
-
log.ok(`Updated ${p}`);
|
|
98
|
-
for (const p of memory.skipped)
|
|
99
|
-
log.skip(`Skipped ${p}`);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
log.warn("LLM unavailable — ensure Result / Test Result describe this follow-up.");
|
|
103
|
-
}
|
|
104
|
-
const body2 = await readText(taskFile);
|
|
105
|
-
resultOk = hasNonEmptySection(body2, "Result");
|
|
106
|
-
testOk = hasNonEmptySection(body2, "Test Result");
|
|
107
|
-
}
|
|
108
|
-
else if (options.skipLlm) {
|
|
109
|
-
log.info(dim("LLM skipped (--skip-llm)."));
|
|
110
|
-
}
|
|
111
|
-
log.info(bold("Sections"));
|
|
112
|
-
log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
|
|
113
|
-
log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
|
|
8
|
+
log.warn(`${yellow("reship is now `ship` (deprecated alias)")} — delegating to \`vibeops task ship --new-cycle\`.`);
|
|
9
|
+
log.info(dim("Run `vibeops task ship` directly; it detects first submit / open-PR update / new PR cycle."));
|
|
114
10
|
log.blank();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (!resultOk || !testOk) {
|
|
128
|
-
log.error("Fill Result and Test Result for this follow-up, then rerun.");
|
|
129
|
-
process.exitCode = 1;
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const dirtyBefore = await listWorkingTreeRelPaths(cwd);
|
|
133
|
-
if (dirtyBefore.length === 0) {
|
|
134
|
-
log.error("No changes to reship. Edit files (e.g. on develop), then rerun.");
|
|
135
|
-
process.exitCode = 1;
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const parts = parseTaskFilename(taskFile);
|
|
139
|
-
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
140
|
-
let gitCtx = await readGitContext(taskFile);
|
|
141
|
-
if (gitCtx === null) {
|
|
142
|
-
log.error("Git Context missing after commit.");
|
|
143
|
-
process.exitCode = 1;
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (updateOpenMr) {
|
|
147
|
-
log.info(dim("Updating open MR/PR (--allow-open-mr)."));
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
|
|
151
|
-
gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
|
|
152
|
-
await commitReshipMetadata({
|
|
153
|
-
cwd,
|
|
154
|
-
taskFile,
|
|
155
|
-
taskId: parts.id,
|
|
156
|
-
dryRun: false,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
const prResult = await finishTaskWithPullRequest({
|
|
160
|
-
cwd,
|
|
161
|
-
taskFile,
|
|
162
|
-
skipPr: options.noPr === true,
|
|
163
|
-
forceNewMergeRequest: !updateOpenMr,
|
|
11
|
+
await taskShipCommand(taskRef, {
|
|
12
|
+
dryRun: options.dryRun,
|
|
13
|
+
cwd: options.cwd,
|
|
14
|
+
noPr: options.noPr,
|
|
15
|
+
newCycle: true,
|
|
16
|
+
noIntegrate: options.noIntegrate,
|
|
17
|
+
recreateBranch: options.recreateBranch,
|
|
18
|
+
skipLlm: options.skipLlm,
|
|
19
|
+
allowOpenMr: options.allowOpenMr,
|
|
164
20
|
});
|
|
165
|
-
if (!prResult.ok) {
|
|
166
|
-
log.error("Reship failed — fix push/MR, then rerun task reship.");
|
|
167
|
-
process.exitCode = 1;
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (prResult.mergeRequestUrl) {
|
|
171
|
-
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
172
|
-
}
|
|
173
|
-
log.blank();
|
|
174
|
-
log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
|
|
175
21
|
}
|
|
@@ -1,13 +1,55 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { readText } from "../lib/filesystem.js";
|
|
3
|
+
import { requireGitConfig } from "../lib/git-config.js";
|
|
4
|
+
import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
|
|
5
|
+
import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
|
|
3
6
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
4
7
|
import { projectPaths } from "../lib/paths.js";
|
|
5
|
-
import {
|
|
8
|
+
import { prNumberFromUrl } from "../lib/pr-create.js";
|
|
9
|
+
import { resolveShipTarget, taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
6
10
|
import { relPath } from "../lib/task-context.js";
|
|
7
|
-
import {
|
|
11
|
+
import { resolveShipCommitMessage } from "../lib/task-commit-msg.js";
|
|
12
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor, } from "../lib/task-git-commit.js";
|
|
13
|
+
import { runPostMergeNewCycle } from "../lib/task-new-cycle.js";
|
|
8
14
|
import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
15
|
+
import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
|
|
9
16
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
10
17
|
import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
18
|
+
function describePushFailure(message) {
|
|
19
|
+
const lower = message.toLowerCase();
|
|
20
|
+
if (lower.includes("protected") ||
|
|
21
|
+
lower.includes("pre-receive") ||
|
|
22
|
+
lower.includes("gh006") ||
|
|
23
|
+
lower.includes("hook declined")) {
|
|
24
|
+
return "Push rejected by branch protection. Adjust the rule on the host (or push via the MR), then rerun.";
|
|
25
|
+
}
|
|
26
|
+
if (lower.includes("non-fast-forward") ||
|
|
27
|
+
lower.includes("fetch first") ||
|
|
28
|
+
lower.includes("rejected") ||
|
|
29
|
+
lower.includes("behind")) {
|
|
30
|
+
return "Push rejected — the remote branch has commits you don't. Pull/rebase the task branch, then rerun.";
|
|
31
|
+
}
|
|
32
|
+
return `git push failed: ${message}`;
|
|
33
|
+
}
|
|
34
|
+
function warnPlaceholderSections(body) {
|
|
35
|
+
const resultOk = hasNonEmptySection(body, "Result");
|
|
36
|
+
const testOk = hasNonEmptySection(body, "Test Result");
|
|
37
|
+
if (!resultOk || !testOk) {
|
|
38
|
+
log.warn("Result / Test Result still look like placeholders (docs-before-ship). Update the TASK file before merge.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function ensureOnTaskBranch(cwd, gitCtx, dryRun) {
|
|
42
|
+
const git = await readGitInfo(cwd);
|
|
43
|
+
if (git.branch === gitCtx.taskBranch)
|
|
44
|
+
return true;
|
|
45
|
+
const where = git.branch ?? "(detached)";
|
|
46
|
+
if (dryRun) {
|
|
47
|
+
log.warn(`Current branch is ${where}, not ${gitCtx.taskBranch}.`);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
log.error(`On ${where}, not ${gitCtx.taskBranch}. Switch with \`git switch ${gitCtx.taskBranch}\` and rerun.`);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
11
53
|
async function commitShipMetadata(opts) {
|
|
12
54
|
if (opts.dryRun) {
|
|
13
55
|
log.info(dim(" would set Status → Shipped"));
|
|
@@ -21,23 +63,10 @@ async function commitShipMetadata(opts) {
|
|
|
21
63
|
log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
|
|
22
64
|
}
|
|
23
65
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const resolved = await resolveTask(paths, cwd, taskRef);
|
|
29
|
-
if (resolved === null) {
|
|
30
|
-
log.error(taskNotFoundMessage(taskRef));
|
|
31
|
-
process.exitCode = 1;
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const { taskId, taskFile } = resolved;
|
|
35
|
-
const meta = await readTaskFile(taskFile);
|
|
36
|
-
const body = await readText(taskFile);
|
|
37
|
-
const relFile = relPath(cwd, taskFile);
|
|
38
|
-
log.info(bold(`vibeops task ship ${taskId}`));
|
|
39
|
-
log.info(` ${dim("file")} ${relFile}`);
|
|
40
|
-
log.blank();
|
|
66
|
+
/** State 1: first submit — push the task branch and open a new PR/MR. */
|
|
67
|
+
async function runFirstShip(ctx) {
|
|
68
|
+
const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options } = ctx;
|
|
69
|
+
let body = ctx.body;
|
|
41
70
|
let resultOk = hasNonEmptySection(body, "Result");
|
|
42
71
|
let testOk = hasNonEmptySection(body, "Test Result");
|
|
43
72
|
if (!resultOk || !testOk) {
|
|
@@ -68,9 +97,9 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
68
97
|
await writeTaskResultSections(taskFile, fb.result, fb.testResult);
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
|
-
|
|
72
|
-
resultOk = hasNonEmptySection(
|
|
73
|
-
testOk = hasNonEmptySection(
|
|
100
|
+
body = dryRun ? body : await readText(taskFile);
|
|
101
|
+
resultOk = hasNonEmptySection(body, "Result");
|
|
102
|
+
testOk = hasNonEmptySection(body, "Test Result");
|
|
74
103
|
}
|
|
75
104
|
log.info(bold("Sections"));
|
|
76
105
|
log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
|
|
@@ -86,15 +115,10 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
86
115
|
if (!resultOk || !testOk) {
|
|
87
116
|
log.info(" · LLM fill Result / Test Result + patch docs/project/*");
|
|
88
117
|
}
|
|
89
|
-
log.info(" · commit implementation + ship metadata (Status → Shipped)");
|
|
90
|
-
log.info(" · push task branch once, open MR/PR (unless --no-pr)");
|
|
118
|
+
log.info(" · commit implementation (TASK-scoped message) + ship metadata (Status → Shipped)");
|
|
119
|
+
log.info(" · push task branch once, open a new MR/PR (unless --no-pr)");
|
|
91
120
|
const partsDry = parseTaskFilename(taskFile);
|
|
92
|
-
await commitShipMetadata({
|
|
93
|
-
cwd,
|
|
94
|
-
taskFile,
|
|
95
|
-
taskId: partsDry.id,
|
|
96
|
-
dryRun: true,
|
|
97
|
-
});
|
|
121
|
+
await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true });
|
|
98
122
|
await finishTaskWithPullRequest({
|
|
99
123
|
cwd,
|
|
100
124
|
taskFile,
|
|
@@ -103,33 +127,223 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
103
127
|
});
|
|
104
128
|
return;
|
|
105
129
|
}
|
|
106
|
-
const parts = parseTaskFilename(taskFile);
|
|
107
|
-
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
108
130
|
const gitCtx = await readGitContext(taskFile);
|
|
109
|
-
if (gitCtx !== null) {
|
|
110
|
-
|
|
131
|
+
if (gitCtx !== null && !(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const parts = parseTaskFilename(taskFile);
|
|
136
|
+
if (options.noCommit === true) {
|
|
137
|
+
const git = await readGitInfo(cwd);
|
|
138
|
+
if (git.dirty === true) {
|
|
139
|
+
log.warn("Uncommitted changes left in place (--no-commit); committing ship metadata only.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const message = await resolveShipCommitMessage({
|
|
111
144
|
cwd,
|
|
112
|
-
taskFile,
|
|
113
145
|
taskId: parts.id,
|
|
114
|
-
|
|
146
|
+
title: meta.title,
|
|
147
|
+
provided: options.message,
|
|
148
|
+
nonInteractive,
|
|
115
149
|
});
|
|
116
|
-
|
|
150
|
+
await commitDirtyWorkingTree(cwd, message, false);
|
|
151
|
+
}
|
|
152
|
+
if (gitCtx === null) {
|
|
153
|
+
log.info(dim("No Git Context — push/MR skipped."));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
|
|
157
|
+
const prResult = await finishTaskWithPullRequest({
|
|
158
|
+
cwd,
|
|
159
|
+
taskFile,
|
|
160
|
+
skipPr: options.noPr === true,
|
|
161
|
+
});
|
|
162
|
+
if (!prResult.ok) {
|
|
163
|
+
log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
log.blank();
|
|
168
|
+
if (prResult.mergeRequestUrl && prResult.mergeRequestUrl !== "(dry-run)") {
|
|
169
|
+
const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
|
|
170
|
+
log.ok(`Created PR ${ref} → ${prResult.mergeRequestUrl}`);
|
|
171
|
+
}
|
|
172
|
+
else if (options.noPr === true) {
|
|
173
|
+
log.ok(`Pushed ${gitCtx.taskBranch} (no PR created — --no-pr).`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
log.ok(`Pushed ${gitCtx.taskBranch} (create the MR/PR manually).`);
|
|
177
|
+
}
|
|
178
|
+
log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
|
|
179
|
+
}
|
|
180
|
+
/** State 2: an open PR exists — commit + push to update it (no new PR). */
|
|
181
|
+
async function runUpdateOpenPr(ctx) {
|
|
182
|
+
const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
|
|
183
|
+
const gitCtx = await readGitContext(taskFile);
|
|
184
|
+
if (gitCtx === null) {
|
|
185
|
+
log.error("No Git Context — cannot update the open PR.");
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let gitCfg;
|
|
190
|
+
try {
|
|
191
|
+
gitCfg = await requireGitConfig(cwd);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const remote = gitCfg.remote;
|
|
199
|
+
if (!(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
warnPlaceholderSections(body);
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
log.info(bold("dry-run — would:"));
|
|
206
|
+
if (options.noCommit === true) {
|
|
207
|
+
log.info(" · skip commit (--no-commit); push committed history only");
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
log.info(" · commit working tree with a TASK-scoped message");
|
|
211
|
+
}
|
|
212
|
+
log.info(` · push ${gitCtx.taskBranch} → ${remote} (no new PR)`);
|
|
213
|
+
log.info(` · update existing PR ${prRefLabel(prCtx.number)} — CI re-runs`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (meta.status === "in_progress") {
|
|
217
|
+
await updateInlineStatus(taskFile, "shipped");
|
|
218
|
+
log.ok("Status → Shipped");
|
|
219
|
+
await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false);
|
|
220
|
+
}
|
|
221
|
+
if (options.noCommit === true) {
|
|
222
|
+
const git = await readGitInfo(cwd);
|
|
223
|
+
if (git.dirty === true) {
|
|
224
|
+
log.warn("Uncommitted changes left in place (--no-commit); pushing committed history only.");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const message = await resolveShipCommitMessage({
|
|
117
229
|
cwd,
|
|
118
|
-
|
|
119
|
-
|
|
230
|
+
taskId,
|
|
231
|
+
title: meta.title,
|
|
232
|
+
provided: options.message,
|
|
233
|
+
nonInteractive,
|
|
120
234
|
});
|
|
121
|
-
|
|
122
|
-
|
|
235
|
+
await commitDirtyWorkingTree(cwd, message, false);
|
|
236
|
+
}
|
|
237
|
+
const remoteExists = await gitRemoteBranchExists(cwd, remote, gitCtx.taskBranch);
|
|
238
|
+
const ahead = remoteExists
|
|
239
|
+
? await gitCommitsAhead(cwd, `${remote}/${gitCtx.taskBranch}`, "HEAD")
|
|
240
|
+
: 1;
|
|
241
|
+
const git = await readGitInfo(cwd);
|
|
242
|
+
const stillDirty = git.dirty === true;
|
|
243
|
+
if (ahead === 0 && !stillDirty) {
|
|
244
|
+
log.blank();
|
|
245
|
+
log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await gitPush(cwd, remote, gitCtx.taskBranch, false);
|
|
250
|
+
log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
254
|
+
log.error(describePushFailure(msg));
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const sha = (await gitHeadCommit(cwd, true)) ?? "HEAD";
|
|
259
|
+
log.blank();
|
|
260
|
+
log.ok(`Updated existing PR ${prRefLabel(prCtx.number)} (pushed ${sha}) — CI re-running`);
|
|
261
|
+
if (prCtx.url)
|
|
262
|
+
log.info(` ${dim("MR/PR")} ${prCtx.url}`);
|
|
263
|
+
}
|
|
264
|
+
/** State 3: the previous PR is merged — start a new PR cycle (guarded). */
|
|
265
|
+
async function runNewCycleFromShip(ctx) {
|
|
266
|
+
const { taskId, prCtx, dryRun, nonInteractive, options } = ctx;
|
|
267
|
+
if (!dryRun && options.newCycle !== true && options.allowOpenMr !== true) {
|
|
268
|
+
if (nonInteractive) {
|
|
269
|
+
log.error(`PR ${prRefLabel(prCtx.number)} is merged. Starting a NEW PR cycle requires --new-cycle in non-interactive mode.`);
|
|
123
270
|
process.exitCode = 1;
|
|
124
271
|
return;
|
|
125
272
|
}
|
|
126
|
-
|
|
127
|
-
|
|
273
|
+
const ok = await askYesNo({
|
|
274
|
+
message: "This starts a NEW PR cycle after merge. Continue?",
|
|
275
|
+
nonInteractive: false,
|
|
276
|
+
defaultValue: false,
|
|
277
|
+
});
|
|
278
|
+
if (!ok) {
|
|
279
|
+
log.info(`Aborted. Use ${cyan("vibeops task add")} for unrelated work, or rerun with ${cyan("--new-cycle")}.`);
|
|
280
|
+
return;
|
|
128
281
|
}
|
|
129
282
|
}
|
|
130
|
-
|
|
131
|
-
|
|
283
|
+
await runPostMergeNewCycle(taskId, {
|
|
284
|
+
dryRun,
|
|
285
|
+
cwd: options.cwd,
|
|
286
|
+
noPr: options.noPr,
|
|
287
|
+
noIntegrate: options.noIntegrate,
|
|
288
|
+
recreateBranch: options.recreateBranch,
|
|
289
|
+
skipLlm: options.skipLlm,
|
|
290
|
+
allowOpenMr: options.allowOpenMr,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** State/PR mismatch: Status is Shipped but no open/merged PR was found. */
|
|
294
|
+
function runMismatchHint(ctx) {
|
|
295
|
+
log.warn(`${ctx.taskId} is Shipped but no open or merged PR was found for its branch.`);
|
|
296
|
+
log.info("The PR was likely merged and the branch synced/deleted.");
|
|
297
|
+
log.info(`Next: start new work with ${cyan("vibeops task add")}, or run ${cyan("vibeops status")} to re-check.`);
|
|
298
|
+
}
|
|
299
|
+
export async function taskShipCommand(taskRef, options = {}) {
|
|
300
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
301
|
+
const paths = projectPaths(cwd);
|
|
302
|
+
const dryRun = options.dryRun === true;
|
|
303
|
+
const nonInteractive = options.nonInteractive === true || !isInteractiveSession();
|
|
304
|
+
const resolved = await resolveShipTarget(paths, cwd, taskRef);
|
|
305
|
+
if (resolved === null) {
|
|
306
|
+
log.error(taskNotFoundMessage(taskRef));
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const { taskId, taskFile } = resolved;
|
|
311
|
+
const meta = await readTaskFile(taskFile);
|
|
312
|
+
const body = await readText(taskFile);
|
|
313
|
+
const relFile = relPath(cwd, taskFile);
|
|
314
|
+
const prCtx = await detectShipPrState(cwd, taskFile);
|
|
315
|
+
const action = resolveShipAction(prCtx.state, meta.status);
|
|
316
|
+
log.info(bold(`vibeops task ship ${taskId}`));
|
|
317
|
+
log.info(` ${dim("file")} ${relFile}`);
|
|
318
|
+
if (prCtx.state !== "none") {
|
|
319
|
+
log.info(` ${dim("PR")} ${prRefLabel(prCtx.number)} (${prCtx.state})`);
|
|
132
320
|
}
|
|
133
321
|
log.blank();
|
|
134
|
-
|
|
322
|
+
const ctx = {
|
|
323
|
+
cwd,
|
|
324
|
+
taskId,
|
|
325
|
+
taskFile,
|
|
326
|
+
meta,
|
|
327
|
+
body,
|
|
328
|
+
relFile,
|
|
329
|
+
prCtx,
|
|
330
|
+
dryRun,
|
|
331
|
+
nonInteractive,
|
|
332
|
+
options,
|
|
333
|
+
};
|
|
334
|
+
switch (action) {
|
|
335
|
+
case "update-open":
|
|
336
|
+
await runUpdateOpenPr(ctx);
|
|
337
|
+
return;
|
|
338
|
+
case "new-cycle":
|
|
339
|
+
await runNewCycleFromShip(ctx);
|
|
340
|
+
return;
|
|
341
|
+
case "mismatch":
|
|
342
|
+
runMismatchHint(ctx);
|
|
343
|
+
return;
|
|
344
|
+
case "first":
|
|
345
|
+
default:
|
|
346
|
+
await runFirstShip(ctx);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
135
349
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { checkbox, confirm, input, select } from "@inquirer/prompts";
|
|
2
2
|
export const OTHER_LABEL = "Other";
|
|
3
|
+
/** True when both stdin and stdout are TTYs (safe to show interactive prompts). */
|
|
4
|
+
export function isInteractiveSession() {
|
|
5
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
6
|
+
}
|
|
3
7
|
function isOther(value) {
|
|
4
8
|
return value === OTHER_LABEL;
|
|
5
9
|
}
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -157,8 +157,13 @@ export async function findMergeRequestByBranches(opts) {
|
|
|
157
157
|
return null;
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
/** Extract the numeric PR/MR id from a host URL (e.g. `#42`), or `null`. */
|
|
161
|
+
export function prNumberFromUrl(url) {
|
|
162
|
+
if (typeof url !== "string")
|
|
163
|
+
return null;
|
|
161
164
|
const trimmed = url.trim();
|
|
165
|
+
if (trimmed.length === 0)
|
|
166
|
+
return null;
|
|
162
167
|
const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
|
|
163
168
|
if (pullMatch)
|
|
164
169
|
return pullMatch[1];
|
|
@@ -167,7 +172,10 @@ function prRefFromUrl(url) {
|
|
|
167
172
|
return mrMatch[1];
|
|
168
173
|
if (/^\d+$/.test(trimmed))
|
|
169
174
|
return trimmed;
|
|
170
|
-
return
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function prRefFromUrl(url) {
|
|
178
|
+
return prNumberFromUrl(url) ?? url.trim();
|
|
171
179
|
}
|
|
172
180
|
export async function getMergeRequestState(cwd, host, url) {
|
|
173
181
|
const ref = prRefFromUrl(url);
|
package/dist/lib/resolve-task.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
|
|
1
|
+
import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, pickLatestShippedTask, } from "./task.js";
|
|
2
2
|
export function normalizeTaskRef(ref) {
|
|
3
3
|
const t = ref.trim();
|
|
4
4
|
if (/^task-/i.test(t) && !/^TASK-/i.test(t)) {
|
|
@@ -26,6 +26,36 @@ export async function resolveTask(paths, cwd, taskRef) {
|
|
|
26
26
|
}
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the TASK that `ship` should act on. Unlike {@link resolveTask}, this
|
|
31
|
+
* also matches an already-Shipped TASK so state-aware ship (update open PR /
|
|
32
|
+
* new PR cycle) can re-run on it.
|
|
33
|
+
* Order: explicit ref → In Progress → active `task/*` branch → latest Shipped.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveShipTarget(paths, cwd, taskRef) {
|
|
36
|
+
const trimmed = taskRef?.trim();
|
|
37
|
+
if (trimmed !== undefined && trimmed.length > 0) {
|
|
38
|
+
const taskId = normalizeTaskRef(trimmed);
|
|
39
|
+
const taskFile = await findTaskFile(paths.docsTasks, taskId);
|
|
40
|
+
if (taskFile === null)
|
|
41
|
+
return null;
|
|
42
|
+
return { taskId, taskFile };
|
|
43
|
+
}
|
|
44
|
+
const tasks = await loadActionableTasks(paths.docsTasks);
|
|
45
|
+
const inProgress = pickInProgressTask(tasks);
|
|
46
|
+
if (inProgress !== null) {
|
|
47
|
+
return { taskId: inProgress.id, taskFile: inProgress.filePath };
|
|
48
|
+
}
|
|
49
|
+
const onBranch = await pickActiveTask(paths.docsTasks, cwd);
|
|
50
|
+
if (onBranch !== null) {
|
|
51
|
+
return { taskId: onBranch.id, taskFile: onBranch.filePath };
|
|
52
|
+
}
|
|
53
|
+
const shipped = pickLatestShippedTask(tasks);
|
|
54
|
+
if (shipped !== null) {
|
|
55
|
+
return { taskId: shipped.id, taskFile: shipped.filePath };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
29
59
|
export function taskNotFoundMessage(taskRef) {
|
|
30
60
|
if (taskRef?.trim()) {
|
|
31
61
|
return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readConfig } from "./config.js";
|
|
2
|
+
import { gitDiffNameOnly, listWorkingTreeRelPaths } from "./git.js";
|
|
3
|
+
import { anyLlmProviderAvailable, llmCompleteText } from "./llm-complete.js";
|
|
4
|
+
import { getLlmPreferenceFromConfig } from "./llm-preference.js";
|
|
5
|
+
import { askInput, isInteractiveSession } from "./inquirer-helpers.js";
|
|
6
|
+
import { dim, log } from "./logger.js";
|
|
7
|
+
import { featCommitMessageFor, taskScopedCommitMessage } from "./task-git-commit.js";
|
|
8
|
+
async function changedPathsForMessage(cwd) {
|
|
9
|
+
const working = await listWorkingTreeRelPaths(cwd);
|
|
10
|
+
if (working.length > 0)
|
|
11
|
+
return working;
|
|
12
|
+
return gitDiffNameOnly(cwd);
|
|
13
|
+
}
|
|
14
|
+
/** Ask the LLM for a one-line conventional-commit subject from the changed files. */
|
|
15
|
+
export async function generateCommitSubjectWithLlm(cwd, taskId, title) {
|
|
16
|
+
const files = await changedPathsForMessage(cwd);
|
|
17
|
+
const fileList = files.slice(0, 40).join("\n") || "(no file changes detected)";
|
|
18
|
+
const messages = [
|
|
19
|
+
{
|
|
20
|
+
role: "system",
|
|
21
|
+
content: "Write a single Conventional Commit subject line (under 72 chars) for the change. " +
|
|
22
|
+
"Reply with ONLY the subject line — no scope, no body, no quotes, no code fences. " +
|
|
23
|
+
"Pick the type from feat, fix, docs, refactor, test, chore.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
role: "user",
|
|
27
|
+
content: [
|
|
28
|
+
`TASK: ${taskId} — ${title}`,
|
|
29
|
+
"",
|
|
30
|
+
"Changed files:",
|
|
31
|
+
fileList,
|
|
32
|
+
].join("\n"),
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
try {
|
|
36
|
+
const config = await readConfig(cwd);
|
|
37
|
+
const preference = getLlmPreferenceFromConfig(config);
|
|
38
|
+
const { text } = await llmCompleteText(messages, { cwd, preference });
|
|
39
|
+
const line = text
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((l) => l.trim())
|
|
42
|
+
.find((l) => l.length > 0);
|
|
43
|
+
if (line === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
return line.replace(/^["'`]+|["'`]+$/g, "").trim() || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the commit subject for a ship, always TASK-id-scoped:
|
|
53
|
+
* 1. explicit `--message`, 2. LLM from diff (when a provider is connected),
|
|
54
|
+
* 3. interactive prompt, 4. TASK-title fallback.
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveShipCommitMessage(opts) {
|
|
57
|
+
const provided = opts.provided?.trim();
|
|
58
|
+
if (provided !== undefined && provided.length > 0) {
|
|
59
|
+
return taskScopedCommitMessage(opts.taskId, provided);
|
|
60
|
+
}
|
|
61
|
+
if (await anyLlmProviderAvailable(opts.cwd)) {
|
|
62
|
+
const subject = await generateCommitSubjectWithLlm(opts.cwd, opts.taskId, opts.title);
|
|
63
|
+
if (subject !== null && subject.length > 0) {
|
|
64
|
+
const msg = taskScopedCommitMessage(opts.taskId, subject);
|
|
65
|
+
log.info(dim(` commit message (LLM): ${msg}`));
|
|
66
|
+
return msg;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!opts.nonInteractive && isInteractiveSession()) {
|
|
70
|
+
const typed = await askInput({
|
|
71
|
+
message: `Commit message for ${opts.taskId}`,
|
|
72
|
+
nonInteractive: false,
|
|
73
|
+
default: opts.title,
|
|
74
|
+
});
|
|
75
|
+
if (typed.trim().length > 0) {
|
|
76
|
+
return taskScopedCommitMessage(opts.taskId, typed);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return featCommitMessageFor(opts.taskId, opts.title);
|
|
80
|
+
}
|
|
@@ -64,21 +64,29 @@ export async function resolveOpenTaskMergeRequest(cwd, taskFile) {
|
|
|
64
64
|
return legacy;
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Single source of truth for a TASK's PR/MR lifecycle and URL.
|
|
69
|
+
* Resolution order: open branch lookup → merged branch lookup → legacy TASK URL.
|
|
70
|
+
* `ship` keys its state machine off the returned `state`.
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveTaskMergeRequestLifecycle(cwd, taskFile) {
|
|
69
73
|
const ctx = await readTaskGitContext(taskFile);
|
|
70
74
|
if (ctx === null)
|
|
71
|
-
return "none";
|
|
75
|
+
return { state: "none", url: null, host: null };
|
|
72
76
|
const open = await resolveFromBranches(cwd, ctx, "open");
|
|
73
77
|
if (open !== null)
|
|
74
|
-
return "open";
|
|
78
|
+
return { state: "open", url: open.url, host: open.host };
|
|
75
79
|
const merged = await resolveFromBranches(cwd, ctx, "merged");
|
|
76
80
|
if (merged !== null)
|
|
77
|
-
return "merged";
|
|
81
|
+
return { state: "merged", url: merged.url, host: merged.host };
|
|
78
82
|
const legacy = await resolveFromTaskFileUrl(cwd, ctx);
|
|
79
83
|
if (legacy !== null)
|
|
80
|
-
return legacy.state;
|
|
81
|
-
return "none";
|
|
84
|
+
return { state: legacy.state, url: legacy.url, host: legacy.host };
|
|
85
|
+
return { state: "none", url: null, host: null };
|
|
86
|
+
}
|
|
87
|
+
/** MR lifecycle for hints, sync, and status — prefers host branch lookup. */
|
|
88
|
+
export async function getTaskMergeRequestLifecycle(cwd, taskFile) {
|
|
89
|
+
return (await resolveTaskMergeRequestLifecycle(cwd, taskFile)).state;
|
|
82
90
|
}
|
|
83
91
|
/** @deprecated Use {@link getTaskMergeRequestLifecycle} or {@link resolveOpenTaskMergeRequest}. */
|
|
84
92
|
export async function getMergeRequestStateForTask(cwd, taskFile) {
|
|
@@ -7,6 +7,30 @@ export function featCommitMessageFor(taskId, title) {
|
|
|
7
7
|
export function docsCommitMessageFor(taskId, suffix) {
|
|
8
8
|
return `docs(${taskId.toLowerCase()}): ${suffix}`;
|
|
9
9
|
}
|
|
10
|
+
const CONVENTIONAL_COMMIT_RE = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/is;
|
|
11
|
+
/**
|
|
12
|
+
* Normalize a raw commit message so it carries the TASK id as the conventional
|
|
13
|
+
* commit scope (git-safety rule: `feat(task-001): …`).
|
|
14
|
+
*
|
|
15
|
+
* - `"fix: handle null"` → `fix(task-001): handle null`
|
|
16
|
+
* - `"fix(login): handle null"` → `fix(task-001): handle null` (scope rewritten)
|
|
17
|
+
* - `"handle null"` → `feat(task-001): handle null` (defaultType)
|
|
18
|
+
* - already-scoped correct messages are returned unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat") {
|
|
21
|
+
const scope = taskId.toLowerCase();
|
|
22
|
+
const msg = rawMessage.trim().replace(/\s+/g, " ");
|
|
23
|
+
if (msg.length === 0)
|
|
24
|
+
return `${defaultType}(${scope}): ${taskId}`;
|
|
25
|
+
const m = CONVENTIONAL_COMMIT_RE.exec(msg);
|
|
26
|
+
if (m) {
|
|
27
|
+
const type = m[1].toLowerCase();
|
|
28
|
+
const bang = m[3] ?? "";
|
|
29
|
+
const subject = m[4].trim();
|
|
30
|
+
return `${type}(${scope})${bang}: ${subject}`;
|
|
31
|
+
}
|
|
32
|
+
return `${defaultType}(${scope}): ${msg}`;
|
|
33
|
+
}
|
|
10
34
|
/** Commit all committable dirty paths; returns whether a commit was created. */
|
|
11
35
|
export async function commitDirtyWorkingTree(cwd, message, dryRun) {
|
|
12
36
|
const git = await readGitInfo(cwd);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { readText } from "./filesystem.js";
|
|
3
|
+
import { listWorkingTreeRelPaths } from "./git.js";
|
|
4
|
+
import { bold, cyan, dim, green, log, yellow } from "./logger.js";
|
|
5
|
+
import { projectPaths } from "./paths.js";
|
|
6
|
+
import { prNumberFromUrl } from "./pr-create.js";
|
|
7
|
+
import { relPath } from "./task-context.js";
|
|
8
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "./task-git-commit.js";
|
|
9
|
+
import { finishTaskWithPullRequest } from "./task-push-pr.js";
|
|
10
|
+
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
|
|
11
|
+
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
|
|
12
|
+
import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
|
|
13
|
+
import { prRefLabel } from "./task-ship-state.js";
|
|
14
|
+
async function commitReshipMetadata(opts) {
|
|
15
|
+
if (opts.dryRun) {
|
|
16
|
+
log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false);
|
|
20
|
+
if (!committed) {
|
|
21
|
+
log.info(dim("No new-cycle metadata commit (Git Context already on disk)."));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Start a NEW PR cycle for an already-Shipped TASK whose previous PR was merged
|
|
26
|
+
* (the former `task reship` behavior). Carries uncommitted work onto the task
|
|
27
|
+
* branch, integrates the integration branch, and opens a fresh MR/PR.
|
|
28
|
+
*/
|
|
29
|
+
export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
30
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
31
|
+
const paths = projectPaths(cwd);
|
|
32
|
+
const dryRun = options.dryRun === true;
|
|
33
|
+
const target = await resolveReshipTarget(paths, cwd, taskRef);
|
|
34
|
+
if (target === null) {
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { taskId, taskFile, meta, gitCtx: initialCtx } = target;
|
|
39
|
+
const relFile = relPath(cwd, taskFile);
|
|
40
|
+
log.info(bold(`vibeops task ship ${taskId} — new PR cycle`));
|
|
41
|
+
log.info(` ${dim("file")} ${relFile}`);
|
|
42
|
+
log.info(` ${dim("branch")} ${initialCtx.taskBranch}`);
|
|
43
|
+
log.info(` ${dim("status")} Shipped (unchanged)`);
|
|
44
|
+
log.blank();
|
|
45
|
+
const gitCfg = dryRun
|
|
46
|
+
? await loadGitConfigForReship(cwd).catch(() => null)
|
|
47
|
+
: await loadGitConfigForReship(cwd);
|
|
48
|
+
if (!dryRun && gitCfg === null) {
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const integrationBranch = gitCfg?.integrationBranch ?? "develop";
|
|
53
|
+
const remote = gitCfg?.remote ?? "origin";
|
|
54
|
+
const updateOpenMr = options.allowOpenMr === true;
|
|
55
|
+
if (!dryRun && !(await assertMergeRequestNotOpen(cwd, taskFile, updateOpenMr))) {
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const branchOk = await ensureTaskBranchForReship({
|
|
60
|
+
cwd,
|
|
61
|
+
taskFile,
|
|
62
|
+
gitCtx: initialCtx,
|
|
63
|
+
integrationBranch,
|
|
64
|
+
remote,
|
|
65
|
+
recreateBranch: options.recreateBranch === true,
|
|
66
|
+
dryRun,
|
|
67
|
+
});
|
|
68
|
+
if (!branchOk) {
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (options.noIntegrate !== true) {
|
|
73
|
+
const integrated = await integrateIntegrationIntoTaskBranch({
|
|
74
|
+
cwd,
|
|
75
|
+
integrationBranch,
|
|
76
|
+
remote,
|
|
77
|
+
dryRun,
|
|
78
|
+
});
|
|
79
|
+
if (!integrated) {
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
log.info(dim("Skipped integration merge (--no-integrate)."));
|
|
86
|
+
}
|
|
87
|
+
const body = await readText(taskFile);
|
|
88
|
+
let resultOk = hasNonEmptySection(body, "Result");
|
|
89
|
+
let testOk = hasNonEmptySection(body, "Test Result");
|
|
90
|
+
if (!options.skipLlm && !dryRun) {
|
|
91
|
+
log.step("LLM — follow-up Result / Test Result");
|
|
92
|
+
const patch = await llmCompleteTaskReship({
|
|
93
|
+
cwd,
|
|
94
|
+
taskId,
|
|
95
|
+
taskTitle: meta.title,
|
|
96
|
+
taskBody: body,
|
|
97
|
+
taskFileRel: relFile,
|
|
98
|
+
});
|
|
99
|
+
if (patch !== null) {
|
|
100
|
+
await writeTaskResultSections(taskFile, patch.result, patch.testResult);
|
|
101
|
+
log.ok(`Result / Test Result updated (${patch.provider})`);
|
|
102
|
+
const memory = await applyTaskShipMemory(cwd, patch);
|
|
103
|
+
for (const p of memory.updated)
|
|
104
|
+
log.ok(`Updated ${p}`);
|
|
105
|
+
for (const p of memory.skipped)
|
|
106
|
+
log.skip(`Skipped ${p}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
log.warn("LLM unavailable — ensure Result / Test Result describe this follow-up.");
|
|
110
|
+
}
|
|
111
|
+
const body2 = await readText(taskFile);
|
|
112
|
+
resultOk = hasNonEmptySection(body2, "Result");
|
|
113
|
+
testOk = hasNonEmptySection(body2, "Test Result");
|
|
114
|
+
}
|
|
115
|
+
else if (options.skipLlm) {
|
|
116
|
+
log.info(dim("LLM skipped (--skip-llm)."));
|
|
117
|
+
}
|
|
118
|
+
log.info(bold("Sections"));
|
|
119
|
+
log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
|
|
120
|
+
log.info(` ${testOk ? green("✓") : yellow("·")} Test Result`);
|
|
121
|
+
log.blank();
|
|
122
|
+
if (dryRun) {
|
|
123
|
+
log.info(bold("dry-run — would also:"));
|
|
124
|
+
if (updateOpenMr) {
|
|
125
|
+
log.info(" · feat commit, push, update open MR/PR (unless --no-pr)");
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
log.info(" · feat commit + metadata, then push once and open a NEW MR/PR (unless --no-pr)");
|
|
129
|
+
}
|
|
130
|
+
log.blank();
|
|
131
|
+
log.info(`Next: ${cyan("vibeops task merge")}, then optional ${cyan("task sync")}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!resultOk || !testOk) {
|
|
135
|
+
log.error("Fill Result and Test Result for this follow-up, then rerun.");
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const dirtyBefore = await listWorkingTreeRelPaths(cwd);
|
|
140
|
+
if (dirtyBefore.length === 0) {
|
|
141
|
+
log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const parts = parseTaskFilename(taskFile);
|
|
145
|
+
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
146
|
+
let gitCtx = await readGitContext(taskFile);
|
|
147
|
+
if (gitCtx === null) {
|
|
148
|
+
log.error("Git Context missing after commit.");
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (updateOpenMr) {
|
|
153
|
+
log.info(dim("Updating open MR/PR (--allow-open-mr)."));
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
|
|
157
|
+
gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
|
|
158
|
+
await commitReshipMetadata({
|
|
159
|
+
cwd,
|
|
160
|
+
taskFile,
|
|
161
|
+
taskId: parts.id,
|
|
162
|
+
dryRun: false,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const prResult = await finishTaskWithPullRequest({
|
|
166
|
+
cwd,
|
|
167
|
+
taskFile,
|
|
168
|
+
skipPr: options.noPr === true,
|
|
169
|
+
forceNewMergeRequest: !updateOpenMr,
|
|
170
|
+
});
|
|
171
|
+
if (!prResult.ok) {
|
|
172
|
+
log.error("New PR cycle failed — fix push/MR, then rerun `vibeops task ship --new-cycle`.");
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
log.blank();
|
|
177
|
+
const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
|
|
178
|
+
if (prResult.mergeRequestUrl) {
|
|
179
|
+
log.ok(`Started new PR cycle → PR ${ref}`);
|
|
180
|
+
log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
log.ok("Started new PR cycle (branch pushed; create the MR/PR manually).");
|
|
184
|
+
}
|
|
185
|
+
log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { prNumberFromUrl } from "./pr-create.js";
|
|
2
|
+
import { resolveTaskMergeRequestLifecycle } from "./task-effective-status.js";
|
|
3
|
+
/**
|
|
4
|
+
* Single entry point for `ship` to learn the current PR/MR state of a TASK.
|
|
5
|
+
* Wraps {@link resolveTaskMergeRequestLifecycle} and extracts the PR number.
|
|
6
|
+
*/
|
|
7
|
+
export async function detectShipPrState(cwd, taskFile) {
|
|
8
|
+
const lifecycle = await resolveTaskMergeRequestLifecycle(cwd, taskFile);
|
|
9
|
+
const state = lifecycle.state === "open"
|
|
10
|
+
? "open"
|
|
11
|
+
: lifecycle.state === "merged"
|
|
12
|
+
? "merged"
|
|
13
|
+
: lifecycle.state === "closed"
|
|
14
|
+
? "closed"
|
|
15
|
+
: "none";
|
|
16
|
+
return {
|
|
17
|
+
state,
|
|
18
|
+
url: lifecycle.url,
|
|
19
|
+
number: prNumberFromUrl(lifecycle.url),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Map (PR state, TASK status) → the `ship` action. Pure so it is unit-testable.
|
|
24
|
+
* - open PR → update the existing PR (state 2)
|
|
25
|
+
* - merged PR → start a new PR cycle (state 3, guarded)
|
|
26
|
+
* - no/closed PR + In Progress → first submit (state 1)
|
|
27
|
+
* - no/closed PR + Shipped → status/PR mismatch (resync hint)
|
|
28
|
+
*/
|
|
29
|
+
export function resolveShipAction(prState, taskStatus) {
|
|
30
|
+
if (prState === "open")
|
|
31
|
+
return "update-open";
|
|
32
|
+
if (prState === "merged")
|
|
33
|
+
return "new-cycle";
|
|
34
|
+
return taskStatus === "shipped" ? "mismatch" : "first";
|
|
35
|
+
}
|
|
36
|
+
/** Format the PR reference for output (`#42` or `the PR`). */
|
|
37
|
+
export function prRefLabel(prNumber) {
|
|
38
|
+
return prNumber !== null ? `#${prNumber}` : "the PR";
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodtek/vibeops",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@9.15.9",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"build": "tsc -p tsconfig.json",
|
|
36
36
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
37
|
"start": "node dist/cli.js",
|
|
38
|
-
"
|
|
38
|
+
"test": "pnpm build && node --test \"tests/*.test.mjs\"",
|
|
39
|
+
"smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
|
|
39
40
|
"clean": "rm -rf dist",
|
|
40
41
|
"prepack": "pnpm clean && pnpm build",
|
|
41
42
|
"publish:dry": "pnpm publish --dry-run --no-git-checks"
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
|
|
3
3
|
- Work from `docs/tasks/TASK-NNN-*.md` only.
|
|
4
4
|
- One TASK at a time.
|
|
5
|
-
- Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN`
|
|
5
|
+
- Fill **Result** and **Test Result** before telling the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running before merge updates the open PR; after merge use `vibeops task ship --new-cycle`.
|
|
6
6
|
- Do not merge to integration or main unless the human runs `vibeops task merge` / `task sync` / `task release`.
|
|
@@ -8,4 +8,4 @@ alwaysApply: true
|
|
|
8
8
|
- Use branch pattern `task/*` from `vibeops task add`.
|
|
9
9
|
- Include TASK id in commits: `feat(task-nnn): …`.
|
|
10
10
|
- No force-push to shared branches.
|
|
11
|
-
- Run `vibeops task ship`, `task
|
|
11
|
+
- Run `vibeops task ship`, `task merge`, `task sync` when the human asks — do not merge locally in the IDE unless requested. (`ship` is state-aware: it updates an open PR or, with `--new-cycle`, starts a new PR cycle after merge.)
|
|
@@ -5,7 +5,7 @@ alwaysApply: true
|
|
|
5
5
|
|
|
6
6
|
# Docs before task ship
|
|
7
7
|
|
|
8
|
-
Before `vibeops task ship
|
|
8
|
+
Before `vibeops task ship`:
|
|
9
9
|
|
|
10
10
|
1. Fill **Result** and **Test Result** in the TASK file (paths, commands, outcomes — facts only).
|
|
11
11
|
2. If architecture or decisions changed, note them in the TASK; `task ship` can update `docs/project/*` via LLM.
|
package/templates/core/AGENTS.md
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
vibeops task add
|
|
9
9
|
# Plan / build in your agent (@docs/tasks/TASK-NNN-*.md)
|
|
10
10
|
|
|
11
|
-
vibeops task ship
|
|
11
|
+
vibeops task ship # state-aware: new PR / update open PR / new cycle
|
|
12
12
|
vibeops task merge # CLI or host UI
|
|
13
13
|
vibeops task sync # optional — branch cleanup only
|
|
14
14
|
|
|
15
|
-
# Same TASK
|
|
16
|
-
vibeops task
|
|
15
|
+
# Same TASK, before merge — just re-run ship to update the open PR:
|
|
16
|
+
vibeops task ship -m "address review"
|
|
17
|
+
# Same TASK, after merge — start a new PR cycle:
|
|
18
|
+
vibeops task ship --new-cycle
|
|
17
19
|
vibeops task merge
|
|
18
20
|
```
|
|
19
21
|
|
|
@@ -26,16 +28,17 @@ Only two values in `## Status`:
|
|
|
26
28
|
| Status | Meaning |
|
|
27
29
|
|--------|---------|
|
|
28
30
|
| **In Progress** | Active slice (`task add`) |
|
|
29
|
-
| **Shipped** | Submitted (`task ship`
|
|
31
|
+
| **Shipped** | Submitted (`task ship`) |
|
|
30
32
|
|
|
31
33
|
Merge and sync do **not** change the TASK file.
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
|
-
vibeops llm connect # optional — task add / ship
|
|
36
|
+
vibeops llm connect # optional — task add / ship
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
## Rules
|
|
38
40
|
|
|
39
41
|
- One **In Progress** TASK at a time; **Shipped** does not block `task add`.
|
|
40
|
-
- Fill **Result** and **Test Result** before `vibeops task ship` (
|
|
41
|
-
-
|
|
42
|
+
- Fill **Result** and **Test Result** before `vibeops task ship` (warned, not blocked, when updating an open PR).
|
|
43
|
+
- `ship` is state-aware: re-run it to update an open PR; after merge it needs `--new-cycle` (or a confirm) to start a new PR cycle. `task reship` still works as a deprecated alias for `task ship --new-cycle`.
|
|
44
|
+
- Agents do not run `task merge` or `task sync` unless the human asks.
|
|
@@ -8,6 +8,6 @@ description: Implement the current VibeOps TASK file in the repo.
|
|
|
8
8
|
1. Read the full `docs/tasks/TASK-NNN-*.md` (Scope, Acceptance Criteria, Test Plan).
|
|
9
9
|
2. Search the codebase before adding new patterns.
|
|
10
10
|
3. Implement only within the TASK scope.
|
|
11
|
-
4. Do not run `vibeops task ship`, `
|
|
11
|
+
4. Do not run `vibeops task ship`, `merge`, or `sync` — the human runs the CLI lifecycle.
|
|
12
12
|
|
|
13
|
-
When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`.
|
|
13
|
+
When implementation is ready, tell the human to run `vibeops task ship TASK-NNN`. `ship` is state-aware: re-running it before merge updates the open PR; after merge it starts a new PR cycle with `vibeops task ship --new-cycle`.
|