Files
Deploymentcenter BotandClaude Opus 5 8f514eb17a feat(2.9.1): Die Begruendung zieht ins Deploymentcenter, die Datei wird erzeugt
Bis hierher galt eine Zweiteilung: Zustand im Deploymentcenter, Begruendung
in docs/ROADMAP.md, verbunden ueber doc_anchor. Die Beschreibung der
uebernommenen Eintraege sagte es woertlich - "Die Begruendung steht weiterhin
in der Datei - hier steht nur der Zustand."

Zwei Folgen, die erst im Betrieb sichtbar wurden:

1. Die Roadmap-Tafel kann die Begruendung nicht zeigen. Sie sieht die
   Datenbank; die Datei liegt auf einem Entwicklungsrechner. Im Seitenpanel
   stand deshalb nur doc_anchor als nicht anklickbarer Text mit dem Hinweis,
   er zeige auf etwas, das das WebUI nie zu sehen bekommt.

2. Schwerer: Die Beschreibung von dc_roadmap wies Agenten an, vor dem
   erneuten Vorschlagen eines zurueckgestellten Vorhabens "diesen Absatz" zu
   lesen. Ein Agent in polytrader oder clawddotnet kann ihn nicht lesen - er
   liegt in einem fremden Repository. Die Anweisung war nicht befolgbar.

Dazu kam, dass cli/roadmap.php --check PHP braucht und auf dem
Windows-Arbeitsplatz keines liegt: Die Haelfte der Pflegemaschinerie, die die
Zweiteilung zusammenhalten sollte, lief dort nie.

== Was sich aendert ==
bugtracker_items und roadmap_epics tragen rationale (Migration 020). Ein
EIGENES Feld neben description, nicht statt dessen: description sagt, was
gemeldet wurde, und kommt oft von einer fremden Maschine; rationale sagt,
warum wir uns so entschieden haben. Zusammengelegt haette jede redaktionelle
Ueberarbeitung den urspruenglichen Wortlaut der Meldung ueberschrieben, also
die Grundlage, auf der entschieden wurde.

Der Verlauf vermerkt nur, DASS die Begruendung sich geaendert hat und um wie
viele Zeichen. Der ganze Absatz ein zweites Mal im Verlauf haette ihn nach
der fuenften Ueberarbeitung unlesbar gemacht.

== Der Weg herauf, einmalig ==
?action=import_rationale liest eine mitgeschickte Roadmap-Datei. Drei Formen
werden erkannt: Bereichsabschnitte, Vorhabensabschnitte und Tabellenzeilen.
Die dritte ist kein Beiwerk - der Abschnitt "Verworfen" fuehrt seine zwoelf
Ablehnungen als Tabelle, und ohne sie bliebe ausgerechnet das nicht am Item
auffindbar, was am haeufigsten gebraucht wird: warum etwas schon einmal
abgelehnt wurde. Ohne overwrite=true bleibt stehen, was im DC schon steht.

== Der Weg herunter, dauerhaft ==
Ein zweites Markerpaar dc:rationale neben dc:roadmap. Bewusst ein zweites und
keine Erweiterung des ersten: Zwischen den beiden steht in jeder gewachsenen
Roadmap-Datei Handgeschriebenes - Einleitung, Leitlinie, Statuszeichen -, und
ein einziger Block haette das beim naechsten Erzeugen verschluckt.
?action=render_file setzt beide Bloecke serverseitig ein, damit die
Markersuche nur einmal umgesetzt ist. Sie hat eine Feinheit, die zweimal
umgesetzt zweimal verschieden waere: Ein im Fliesstext ERWAEHNTER Marker darf
nicht gefunden werden - die Einleitung dieser Datei erwaehnt jetzt beide.

== Werkzeuge ==
scripts/dc.py roadmap-import und roadmap-write, in Python und nicht in
cli/roadmap.php - sonst waeren sie auf dem Arbeitsplatz wieder nicht
aufrufbar. roadmap-write behaelt die Zeilenenden der Datei bei.

== Bestand ==
35 Vorhaben und 6 Bereiche aus docs/ROADMAP.md uebernommen, 476 Zeilen
handgepflegte Prosa durch den erzeugten Block ersetzt. Gegengeprueft auf
Wortebene: Aus dem Fliesstext fehlt nichts, die einzigen Abweichungen sind
die ersetzten Ueberschriften und die alte Einleitung.

Kaertchen ohne hinterlegte Begruendung tragen auf der Tafel eine dezente
Marke - erledigte nicht, dort waere sie ein Vorwurf ohne Adressaten.

MCP-Adapter auf 1.3.0: dc_roadmap_set nimmt rationale, und die Beschreibung
von dc_roadmap verweist nicht mehr auf eine Datei, die der Agent nicht
erreichen kann.

Geprueft: 7 neue Testfaelle, Suite gesamt 179 von 179.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:59:06 +02:00

326 lines
11 KiB
PHP

#!/usr/bin/env php
<?php
/**
* Roadmap — Zustandsblock erzeugen und Prosa abgleichen.
*
* SEIT 2.9.1 IST DER HAUPTWEG EIN ANDERER
*
* Die Begruendungen stehen jetzt ebenfalls im Deploymentcenter (Feld
* `rationale`), und die Datei wird vollstaendig daraus erzeugt - beide
* Bloecke, Zustand und Begruendungen:
*
* python scripts/dc.py roadmap-write --project deploymentcenter
*
* Dieses Skript schreibt weiterhin nur den Zustandsblock und kennt den
* Begruendungsblock nicht. Es bleibt fuer den Fall, dass jemand auf dem
* Server direkt arbeitet; auf dem Windows-Arbeitsplatz ist es ohnehin nicht
* ausfuehrbar, weil dort kein PHP liegt. Genau das war einer der Gruende
* fuer die Umstellung: Die Haelfte der Pflegemaschinerie, die die alte
* Zweiteilung zusammenhalten sollte, lief dort nie.
*
* DAS PROBLEM, DAS DIESES SKRIPT URSPRUENGLICH LOESTE
*
* Roadmap-Zustand steht im Deploymentcenter, die Begruendung in einer
* Markdown-Datei im Projektrepository. Zwei Orte fuer einen Vorgang - und
* damit die Frage, wie sie zusammenbleiben.
*
* Die Antwort war nicht "besser aufpassen", sondern zweigeteilt:
*
* --write Der Zustandsteil der Datei wird ERZEUGT, nicht gepflegt.
* Zwischen den Markern kann nichts driften, weil dort niemand
* tippt. Ausserhalb der Marker wird nichts angefasst.
*
* --check Was von Hand geschrieben ist - die Prosa - wird gegen die
* Items geprueft. Verweist ein Absatz auf ein Item, das es
* nicht gibt? Gehoert es einem anderen Projekt? Fehlt zu einer
* zurueckgestellten Entscheidung die Begruendung?
*
* Aufrufe:
* php cli/roadmap.php --check --project=deploymentcenter
* php cli/roadmap.php --write --project=deploymentcenter
* php cli/roadmap.php --list --project=deploymentcenter
* php cli/roadmap.php --check --project=polytrader --file=../PolyTrader/ROADMAP.md
* php cli/roadmap.php --help
*
* Ohne --file wird docs/ROADMAP.md im Deploymentcenter-Verzeichnis genommen.
*
* Rueckgabewerte:
* 0 in Ordnung (bei --check: keine Fehler, Warnungen erlaubt)
* 1 Fehler im Abgleich oder Abbruch
* 2 nichts zu tun (Projekt ohne Roadmap-Eintraege)
*
* Gedacht fuer den Aufruf von Hand und aus scripts/deploy.py heraus. Das
* Projekt hat keine CI-Strecke; ein Skript, das man ausfuehren kann, ist
* deshalb die richtige Groesse - eine Pipeline waere ein eigenes Vorhaben.
*/
declare(strict_types=1);
use Deploymentcenter\Core\Db;
use Deploymentcenter\Modules\Bugtracker\Roadmap;
// ---------------------------------------------------------------------------
// Nur Kommandozeile. Wie bei tick.php: wirkt unabhaengig davon, ob die
// Webroot-Sperre greift.
// ---------------------------------------------------------------------------
if (PHP_SAPI !== 'cli') {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
exit("Dieses Skript laeuft ausschliesslich auf der Kommandozeile.\n");
}
require_once __DIR__ . '/../src/bootstrap.php';
set_exception_handler(static function (\Throwable $e): void {
fwrite(STDERR, sprintf(
"[FEHLER] %s: %s\n %s:%d\n",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
exit(1);
});
$options = parseArguments($argv);
if ($options['help']) {
printHelp();
exit(0);
}
$project = $options['project'];
if ($project === null) {
fwrite(STDERR, "Es fehlt --project=<slug>. Eine Roadmap gehoert zu genau einem Projekt.\n");
exit(1);
}
$file = $options['file'] ?? (DC_ROOT . '/docs/ROADMAP.md');
$db = Db::init();
$roadmap = new Roadmap($db);
$mode = $options['mode'];
// ---------------------------------------------------------------------------
// --list: nur ansehen, nichts anfassen
// ---------------------------------------------------------------------------
if ($mode === 'list') {
$stats = $roadmap->stats($project);
if ($stats['total'] === 0) {
fwrite(STDOUT, sprintf("Projekt \"%s\" hat keine Roadmap-Eintraege.\n", $project));
exit(2);
}
fwrite(STDOUT, sprintf(
"%s — %d Vorhaben: %d offen, %d in Arbeit, %d erledigt, %d zurueckgestellt, %d verworfen\n\n",
$project,
$stats['total'],
$stats['pending'],
$stats['working'],
$stats['done'],
$stats['deferred'],
$stats['rejected']
));
foreach ($roadmap->epics($project) as $epic) {
fwrite(STDOUT, sprintf(
" [%s] %-40s %d Eintrag/Eintraege\n",
(string)$epic['epic'],
(string)$epic['title'],
(int)$epic['total']
));
}
fwrite(STDOUT, "\nAls Naechstes:\n");
foreach ($roadmap->nextUp($project, 5) as $i => $item) {
fwrite(STDOUT, sprintf(
" %d. [%s] #%d %s%s\n",
$i + 1,
(string)$item['epic'],
(int)$item['id'],
(string)$item['title'],
$item['claimed_by'] !== null ? ' (in Arbeit: ' . $item['claimed_by'] . ')' : ''
));
}
exit(0);
}
// ---------------------------------------------------------------------------
// --write: Zustandsblock erzeugen und einsetzen
// ---------------------------------------------------------------------------
if ($mode === 'write') {
$block = $roadmap->renderBlock($project);
if ($options['stdout']) {
fwrite(STDOUT, $block . "\n");
exit(0);
}
$existing = is_file($file) ? (string)file_get_contents($file) : '';
$updated = Roadmap::spliceBlock($existing, $block);
if ($existing === $updated) {
fwrite(STDOUT, sprintf("%s ist bereits auf dem Stand.\n", $file));
exit(0);
}
// Erst schreiben, dann melden. Ein fehlgeschlagenes file_put_contents
// duerfte nicht als Erfolg durchgehen - sonst haelt der naechste Lauf die
// Datei fuer aktuell.
if (file_put_contents($file, $updated) === false) {
fwrite(STDERR, sprintf("Konnte %s nicht schreiben.\n", $file));
exit(1);
}
$wasNew = strpos($existing, Roadmap::BLOCK_START) === false;
fwrite(STDOUT, sprintf(
"%s %s.\n",
$file,
$wasNew ? 'ergaenzt - der erzeugte Block wurde angehaengt' : 'aktualisiert'
));
if ($wasNew && $existing !== '') {
fwrite(STDOUT,
"Hinweis: Der Block steht am Dateiende. Verschiebe ihn samt Marker an die\n"
. " gewuenschte Stelle - kuenftige Laeufe finden ihn dort wieder.\n"
);
}
exit(0);
}
// ---------------------------------------------------------------------------
// --check: Prosa gegen die Items
// ---------------------------------------------------------------------------
$result = $roadmap->check($project, $file);
if ($options['json']) {
fwrite(STDOUT, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
exit($result['ok'] ? 0 : 1);
}
fwrite(STDOUT, sprintf("Abgleich %s gegen Projekt \"%s\"\n", $file, $project));
fwrite(STDOUT, sprintf("%d Item-Verweis(e) in der Prosa geprueft.\n", $result['checked']));
// Ein veralteter Block ist kein Fehler - er ist eine Kopie, und Kopien altern.
// Gemeldet wird er trotzdem, damit niemand eine Woche alte Liste fuer den
// Stand von heute haelt.
if ($result['stale'] !== null) {
$age = time() - (int)strtotime($result['stale']);
if ($age > 86400) {
fwrite(STDOUT, sprintf(
"\n[Hinweis] Der erzeugte Block ist vom %s, also %d Tag(e) alt.\n"
. " `--write` bringt ihn auf den Stand. Der Live-Stand steht im DC.\n",
$result['stale'],
(int)floor($age / 86400)
));
}
} else {
fwrite(STDOUT,
"\n[Hinweis] Die Datei enthaelt keinen erzeugten Block. `--write` legt ihn an;\n"
. " bis dahin steht der Zustand nur im Deploymentcenter.\n"
);
}
if ($result['findings'] === []) {
fwrite(STDOUT, "\nKeine Abweichungen.\n");
exit(0);
}
$errors = 0;
$warnings = 0;
fwrite(STDOUT, "\n");
foreach ($result['findings'] as $finding) {
$isError = $finding['level'] === 'error';
$isError ? $errors++ : $warnings++;
fwrite($isError ? STDERR : STDOUT, sprintf(
" %s [%s] %s\n",
$isError ? 'FEHLER ' : 'Warnung',
$finding['code'],
$finding['message']
));
}
fwrite(STDOUT, sprintf("\n%d Fehler, %d Warnung(en).\n", $errors, $warnings));
exit($errors > 0 ? 1 : 0);
// ===========================================================================
// Hilfsfunktionen
// ===========================================================================
/**
* @param list<string> $argv
* @return array{mode:string,project:?string,file:?string,json:bool,stdout:bool,help:bool}
*/
function parseArguments(array $argv): array
{
$options = [
'mode' => 'check',
'project' => null,
'file' => null,
'json' => false,
'stdout' => false,
'help' => false,
];
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--help' || $arg === '-h') {
$options['help'] = true;
} elseif ($arg === '--check') {
$options['mode'] = 'check';
} elseif ($arg === '--write') {
$options['mode'] = 'write';
} elseif ($arg === '--list') {
$options['mode'] = 'list';
} elseif ($arg === '--json') {
$options['json'] = true;
} elseif ($arg === '--stdout') {
$options['stdout'] = true;
} elseif (str_starts_with($arg, '--project=')) {
$options['project'] = substr($arg, 10);
} elseif (str_starts_with($arg, '--file=')) {
$options['file'] = substr($arg, 7);
} else {
fwrite(STDERR, sprintf("Unbekannte Option \"%s\". --help zeigt die Aufrufe.\n", $arg));
exit(1);
}
}
return $options;
}
function printHelp(): void
{
fwrite(STDOUT, <<<'TEXT'
Roadmap — Zustandsblock erzeugen und Prosa abgleichen.
php cli/roadmap.php --check --project=<slug> [--file=<pfad>] [--json]
php cli/roadmap.php --write --project=<slug> [--file=<pfad>] [--stdout]
php cli/roadmap.php --list --project=<slug>
Was wo gepflegt wird:
Zustand Status, Reihenfolge, Zielfassung, Bearbeiter
-> Wahrheit ist das Deploymentcenter. Die Datei zeigt eine
erzeugte Kopie zwischen den Markern.
Begruendung Warum, verworfen, Ausloeser, Herleitung
-> Wahrheit ist die Datei, ausserhalb der Marker. Das Item
haelt nur den Verweis darauf (doc_anchor).
Kein Feld hat zwei Pfleger. Deshalb kann der Zustandsteil nicht driften -
und deshalb prueft --check nur die Prosa.
Rueckgabewerte: 0 in Ordnung, 1 Fehler, 2 nichts zu tun.
TEXT);
}