feat(projects): approval guard + worktree-aware ralph execution
Two structural fixes that together let users manage feature-branch
work without manual intervention:
Approval guard — `/plan/start` returns 409 `already_committed` if the
project status is approved/running/complete, unless the body opts in
with `force=true`. Frontend now renders "Re-planifică" instead of
"Planifică" on approved cards and gates it behind a confirm dialog
that threads `force=true` through. Prevents an accidental click from
wiping `status=approved` and burning a fresh planning subprocess.
Worktree awareness — projects can now declare that they target a
feature branch on an existing Gitea repo, not a repo-per-slug clone.
Three optional fields added to approved-tasks.json: `repo` (default
= slug), `branch` (feature branch to create), `base_branch` (default
main). Wired through `/p` flag parser in router.py, the dashboard
Propose modal's new "Avansat" section, and the night-execute prompt
which clones {repo} and creates {branch} from {base_branch} before
running ralph.
CLAUDE.md updated with both flows + the new schema fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -629,6 +629,9 @@ class ProjectsHandlers:
|
||||
return # parse_json_body already sent 400
|
||||
slug = (body.get("slug") or "").strip()
|
||||
description = (body.get("description") or "").strip()
|
||||
repo = (body.get("repo") or "").strip() or None
|
||||
branch = (body.get("branch") or "").strip() or None
|
||||
base_branch = (body.get("base_branch") or "").strip() or None
|
||||
|
||||
slug_err = validate_slug(slug)
|
||||
desc_err = validate_description(description)
|
||||
@@ -659,6 +662,9 @@ class ProjectsHandlers:
|
||||
"status": "pending",
|
||||
"planning_session_id": None,
|
||||
"final_plan_path": None,
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"base_branch": base_branch,
|
||||
"proposed_at": _now_iso(),
|
||||
"approved_at": None,
|
||||
"started_at": None,
|
||||
@@ -825,13 +831,30 @@ class ProjectsHandlers:
|
||||
if body is None:
|
||||
return
|
||||
description = (body.get("description") or "").strip()
|
||||
force = bool(body.get("force"))
|
||||
|
||||
# Approval guard: refuse to silently overwrite approved/running/complete
|
||||
# projects. Caller must opt in with force=true (the "Re-planifică" path).
|
||||
existing_proj = _find_project(_read_approved(), slug)
|
||||
if existing_proj and not force:
|
||||
current_status = (existing_proj.get("status") or "").lower()
|
||||
if current_status in {"approved", "running", "complete"}:
|
||||
self.send_json({
|
||||
"error": "already_committed",
|
||||
"slug": slug,
|
||||
"status": current_status,
|
||||
"message": (
|
||||
f"Proiectul `{slug}` e deja `{current_status}`. "
|
||||
"Folosește «Re-planifică» (cu force=true) ca să reiei planning-ul."
|
||||
),
|
||||
}, 409)
|
||||
return
|
||||
|
||||
# Description fallback: use the existing approved-tasks description so
|
||||
# users can re-open planning on a project they already proposed.
|
||||
if not description:
|
||||
existing = _find_project(_read_approved(), slug)
|
||||
if existing:
|
||||
description = (existing.get("description") or "").strip()
|
||||
if existing_proj:
|
||||
description = (existing_proj.get("description") or "").strip()
|
||||
desc_err = validate_description(description) if description else "description required"
|
||||
if desc_err:
|
||||
self.send_json({"error": "invalid_description", "message": desc_err}, 400)
|
||||
@@ -847,6 +870,9 @@ class ProjectsHandlers:
|
||||
"status": "planning",
|
||||
"planning_session_id": None,
|
||||
"final_plan_path": None,
|
||||
"repo": None,
|
||||
"branch": None,
|
||||
"base_branch": None,
|
||||
"proposed_at": _now_iso(),
|
||||
"approved_at": None,
|
||||
"started_at": None,
|
||||
|
||||
@@ -930,6 +930,30 @@
|
||||
<input type="checkbox" id="proposeWithPlanning" checked>
|
||||
<span>Planifică cu Echo imediat după propunere</span>
|
||||
</label>
|
||||
<details class="form-advanced">
|
||||
<summary>Avansat — feature pe repo existent</summary>
|
||||
<p class="form-hint" style="font-size:0.85em;color:var(--text-muted);margin:0.5em 0;">
|
||||
Setează doar dacă slug-ul nu corespunde unui repo Gitea (e o feature pe alt repo).
|
||||
</p>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="proposeRepo">Repo Gitea</label>
|
||||
<input type="text" id="proposeRepo" class="form-input" name="repo"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="ex: roa2web (default = slug)">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="proposeBranch">Branch nou</label>
|
||||
<input type="text" id="proposeBranch" class="form-input" name="branch"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="ex: feature/telegram-bonuri">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="proposeBaseBranch">Base branch</label>
|
||||
<input type="text" id="proposeBaseBranch" class="form-input" name="base_branch"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="default: main">
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-sm secondary" data-close="propose">Anulează</button>
|
||||
@@ -1280,8 +1304,8 @@
|
||||
];
|
||||
case 'approved':
|
||||
return [
|
||||
{ label: 'Dezaprobă', type: 'secondary', action: () => unapproveProject(slug) },
|
||||
{ label: 'Planifică', type: 'ghost', action: () => startPlanning(slug, p.description) },
|
||||
{ label: 'Dezaprobă', type: 'secondary', action: () => unapproveProject(slug) },
|
||||
{ label: 'Re-planifică', type: 'ghost', action: () => replanProject(slug, p.description) },
|
||||
];
|
||||
case 'pending':
|
||||
return [
|
||||
@@ -1657,6 +1681,9 @@
|
||||
clearProposeErrors();
|
||||
const slug = (proposeSlug.value || '').trim();
|
||||
const desc = (proposeDesc.value || '').trim();
|
||||
const repo = (document.getElementById('proposeRepo')?.value || '').trim();
|
||||
const branch = (document.getElementById('proposeBranch')?.value || '').trim();
|
||||
const baseBranch = (document.getElementById('proposeBaseBranch')?.value || '').trim();
|
||||
let invalid = false;
|
||||
if (!slug || !/^[a-z0-9][a-z0-9_-]{0,49}$/i.test(slug)) {
|
||||
proposeSlugErr.textContent = 'Slug invalid (folosește litere/cifre/_/-, max 50)';
|
||||
@@ -1675,10 +1702,14 @@
|
||||
proposeSubmit.textContent = 'Se trimite…';
|
||||
|
||||
try {
|
||||
const body = { slug, description: desc };
|
||||
if (repo) body.repo = repo;
|
||||
if (branch) body.branch = branch;
|
||||
if (baseBranch) body.base_branch = baseBranch;
|
||||
const res = await apiFetch('/echo/api/projects/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, description: desc }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 400) {
|
||||
const data = await safeJson(res);
|
||||
@@ -1809,6 +1840,15 @@
|
||||
await openPlanModal(slug, true, description);
|
||||
}
|
||||
|
||||
async function replanProject(slug, description) {
|
||||
const ok = confirm(
|
||||
'Anulezi aprobarea curentă pentru `' + slug + '` și restartezi planning-ul?\n\n' +
|
||||
'Aprobarea (data și final-plan.md) va fi pierdută; vei reintra în Office hours.'
|
||||
);
|
||||
if (!ok) return;
|
||||
await openPlanModal(slug, true, description, /* force */ true);
|
||||
}
|
||||
|
||||
async function rollbackProject(slug) {
|
||||
if (!confirm('Rollback (git revert HEAD) pe ' + slug + '? Decrementează ultima poveste trecută.')) return;
|
||||
try {
|
||||
@@ -2075,12 +2115,45 @@
|
||||
setPhase(null, []);
|
||||
}
|
||||
|
||||
async function openPlanModal(slug, startIfMissing, descriptionHint) {
|
||||
async function openPlanModal(slug, startIfMissing, descriptionHint, force) {
|
||||
resetPlanModal();
|
||||
state.planning.slug = slug;
|
||||
planSlugEl.textContent = slug;
|
||||
openModal(planModal, { focusSelector: '#composerInput' });
|
||||
|
||||
// Re-planning: skip transcript reuse, go straight to fresh start.
|
||||
if (force) {
|
||||
appendTypingIndicator();
|
||||
startElapsedCounter();
|
||||
try {
|
||||
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: descriptionHint || '', force: true }),
|
||||
});
|
||||
removeTypingIndicator();
|
||||
stopElapsedCounter();
|
||||
if (res.ok) {
|
||||
const data = await safeJson(res);
|
||||
if (data) {
|
||||
setPhase(data.phase || '/office-hours', []);
|
||||
if (data.message) appendMessage('assistant', data.message);
|
||||
}
|
||||
await fetchProjects();
|
||||
} else {
|
||||
const d = await safeJson(res);
|
||||
appendMessage('assistant', '_Eroare la re-planning: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
|
||||
}
|
||||
} catch (err) {
|
||||
removeTypingIndicator();
|
||||
stopElapsedCounter();
|
||||
if (err.message !== 'unauthorized') {
|
||||
appendMessage('assistant', '_Eroare: ' + (err.message || err) + '_');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to fetch existing transcript first
|
||||
try {
|
||||
const tRes = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/transcript');
|
||||
@@ -2133,7 +2206,13 @@
|
||||
await fetchProjects();
|
||||
} else {
|
||||
const d = await safeJson(res);
|
||||
appendMessage('assistant', '_Eroare la pornirea planning: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
|
||||
if (res.status === 409 && d && d.error === 'already_committed') {
|
||||
appendMessage('assistant',
|
||||
'_' + (d.message || 'Proiectul e deja committed.') +
|
||||
' Folosește «Re-planifică» dacă vrei să o iei de la zero._');
|
||||
} else {
|
||||
appendMessage('assistant', '_Eroare la pornirea planning: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
removeTypingIndicator();
|
||||
|
||||
Reference in New Issue
Block a user