@trycore/spec-build-harness 0.7.1 → 0.8.1
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/.claude-plugin/plugin.json +1 -1
- package/GOVERNANCE.md +3 -3
- package/METODOLOGIA.md +44 -0
- package/README.md +60 -5
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +6 -0
- package/commands/build/front.md +15 -0
- package/commands/build/resume.md +29 -0
- package/config/build-config.template.json +8 -0
- package/dist/commands/init.js +15 -5
- package/dist/commands/status.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/lib/paths.js +7 -0
- package/dist/lib/settings-merge.js +29 -2
- package/dist/lib/state-seed.js +14 -0
- package/docs/commands.md +15 -3
- package/docs/decisiones/2026-07-03-gsd-vs-openspec-fork-vs-rama.md +157 -0
- package/docs/hooks.md +22 -5
- package/hooks/build/build-gate-check.sh +1 -1
- package/hooks/build/context-monitor.sh +80 -0
- package/hooks/build/design-source-guard.sh +1 -1
- package/hooks/build/lib/state-io.sh +55 -0
- package/hooks/build/lint-typecheck.sh +1 -1
- package/hooks/build/load-build-state.sh +46 -2
- package/hooks/build/reconcile-build-state.py +70 -0
- package/hooks/build/reflect-nudge.sh +1 -1
- package/hooks/build/release-gate-nudge.sh +1 -1
- package/hooks/build/scaffold-guard.sh +1 -1
- package/hooks/build/stack-guard.sh +1 -1
- package/hooks/build/statusline-bridge.sh +32 -0
- package/hooks/build-harness.json +24 -0
- package/package.json +1 -1
- package/scripts/lib/front-plan.py +47 -0
- package/scripts/smoke-test.sh +12 -0
- package/scripts/tests/test-context-monitor.sh +106 -0
- package/scripts/tests/test-front-plan.sh +38 -0
- package/scripts/tests/test-install.sh +89 -0
- package/scripts/tests/test-reconciler.sh +54 -0
- package/scripts/tests/test-schema.sh +58 -0
- package/skills/building-a-slice/references/dor.md +2 -0
- package/skills/managing-parallel-front/SKILL.md +36 -0
- package/state/build-state.schema.json +50 -0
package/hooks/build-harness.json
CHANGED
|
@@ -52,6 +52,26 @@
|
|
|
52
52
|
"command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/coherence-flag.sh\""
|
|
53
53
|
}
|
|
54
54
|
]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"matcher": "Bash|Edit|Write|MultiEdit|Task",
|
|
58
|
+
"hooks": [
|
|
59
|
+
{
|
|
60
|
+
"type": "command",
|
|
61
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"PreCompact": [
|
|
67
|
+
{
|
|
68
|
+
"matcher": ".*",
|
|
69
|
+
"hooks": [
|
|
70
|
+
{
|
|
71
|
+
"type": "command",
|
|
72
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
|
|
73
|
+
}
|
|
74
|
+
]
|
|
55
75
|
}
|
|
56
76
|
],
|
|
57
77
|
"Stop": [
|
|
@@ -69,6 +89,10 @@
|
|
|
69
89
|
{
|
|
70
90
|
"type": "command",
|
|
71
91
|
"command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/release-gate-nudge.sh\""
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"type": "command",
|
|
95
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
|
|
72
96
|
}
|
|
73
97
|
]
|
|
74
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trycore/spec-build-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""front-plan.py — selecciona el conjunto disjunto máximo de épicas no fundacionales.
|
|
3
|
+
|
|
4
|
+
stdin: JSON [{"epica","layer","files_scope":[glob]}]
|
|
5
|
+
stdout: {"selected":[...],"serialized":[{"epica","reason"}],"excluded_foundational":[...]}
|
|
6
|
+
Determinista: orden por 'epica'. Solape de globs por prefijo de directorio o glob idéntico.
|
|
7
|
+
"""
|
|
8
|
+
import json, sys, fnmatch
|
|
9
|
+
|
|
10
|
+
def overlap(a, b):
|
|
11
|
+
for ga in a:
|
|
12
|
+
for gb in b:
|
|
13
|
+
if ga == gb or fnmatch.fnmatch(ga, gb) or fnmatch.fnmatch(gb, ga):
|
|
14
|
+
return True
|
|
15
|
+
pa, pb = ga.split("*", 1)[0], gb.split("*", 1)[0]
|
|
16
|
+
# Fail closed: a leading/empty-prefix wildcard could match anything → assume overlap.
|
|
17
|
+
if not pa or not pb:
|
|
18
|
+
return True
|
|
19
|
+
if pa.startswith(pb) or pb.startswith(pa):
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def _empty_result():
|
|
24
|
+
return {"selected": [], "serialized": [], "excluded_foundational": []}
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
try:
|
|
28
|
+
cands = json.load(sys.stdin)
|
|
29
|
+
if not isinstance(cands, list) or not all(isinstance(c, dict) for c in cands):
|
|
30
|
+
raise ValueError("entrada inválida: se esperaba una lista de objetos")
|
|
31
|
+
cands = sorted(cands, key=lambda c: c.get("epica",""))
|
|
32
|
+
excluded = [c["epica"] for c in cands if c.get("layer") == "foundational"]
|
|
33
|
+
pool = [c for c in cands if c.get("layer") != "foundational"]
|
|
34
|
+
selected, sel_scopes, serialized = [], [], []
|
|
35
|
+
for c in pool:
|
|
36
|
+
sc = c.get("files_scope") or []
|
|
37
|
+
if any(overlap(sc, s) for s in sel_scopes):
|
|
38
|
+
serialized.append({"epica": c["epica"], "reason": "solape de files_scope con épica seleccionada"})
|
|
39
|
+
else:
|
|
40
|
+
selected.append(c["epica"]); sel_scopes.append(sc)
|
|
41
|
+
print(json.dumps({"selected":selected,"serialized":serialized,"excluded_foundational":excluded}))
|
|
42
|
+
except Exception:
|
|
43
|
+
print(json.dumps(_empty_result()))
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# smoke-test.sh — corre los tests de humo del motor de contexto y front.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
5
|
+
FAILED=0
|
|
6
|
+
|
|
7
|
+
echo "── Tests del motor de contexto y front ──"
|
|
8
|
+
for t in test-schema.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
|
|
9
|
+
if bash "$DIR/tests/$t"; then echo "smoke: $t OK"; else echo "smoke: $t FALLÓ"; FAILED=1; fi
|
|
10
|
+
done
|
|
11
|
+
|
|
12
|
+
exit $FAILED
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
4
|
+
fail=0
|
|
5
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
6
|
+
export TMPDIR="$TMP"
|
|
7
|
+
|
|
8
|
+
# bridge: escribe puente sanitizando session_id
|
|
9
|
+
echo '{"session_id":"ab/../cd","context_window":{"remaining_percentage":22}}' \
|
|
10
|
+
| bash "$ROOT/hooks/build/statusline-bridge.sh" >/dev/null
|
|
11
|
+
if ls "$TMP"/claude-ctx-*.json >/dev/null 2>&1; then
|
|
12
|
+
b="$(ls "$TMP"/claude-ctx-*.json)"
|
|
13
|
+
case "$b" in *..*|*/claude-ctx-ab/*) echo "FAIL bridge no sanitiza"; fail=1;; *) echo "OK bridge sanitiza";; esac
|
|
14
|
+
rp="$(python3 -c "import json;print(json.load(open('$b'))['remaining_pct'])")"
|
|
15
|
+
[ "$rp" = 22 ] && echo "OK bridge remaining_pct" || { echo "FAIL bridge remaining_pct ($rp)"; fail=1; }
|
|
16
|
+
else echo "FAIL bridge no escribió puente"; fail=1; fi
|
|
17
|
+
|
|
18
|
+
# monitor: a 20% (critical<25) inyecta additionalContext y escribe handoff
|
|
19
|
+
sid="sess1"; echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sid.json"
|
|
20
|
+
mkdir -p "$TMP/proj/.claude/state" "$TMP/proj/.claude/config"
|
|
21
|
+
cat > "$TMP/proj/.claude/state/build-state.json" <<'JSON'
|
|
22
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
23
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
|
|
24
|
+
"gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t","progress_log":[]},
|
|
25
|
+
"history":[],"releases":[]}
|
|
26
|
+
JSON
|
|
27
|
+
echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$TMP/proj/.claude/config/build-config.json"
|
|
28
|
+
out="$(echo "{\"session_id\":\"$sid\",\"hook_event_name\":\"PostToolUse\"}" \
|
|
29
|
+
| CLAUDE_PROJECT_DIR="$TMP/proj" bash "$ROOT/hooks/build/context-monitor.sh")"
|
|
30
|
+
echo "$out" | grep -q 'additionalContext' && echo "OK monitor inyecta aviso" || { echo "FAIL monitor sin aviso"; fail=1; }
|
|
31
|
+
sc="$(python3 -c "import json;print(json.load(open('$TMP/proj/.claude/state/build-state.json'))['active_slice'].get('session_continuity',{}).get('critical_recorded'))" 2>/dev/null)"
|
|
32
|
+
[ "$sc" = True ] && echo "OK monitor handoff" || { echo "FAIL monitor handoff ($sc)"; fail=1; }
|
|
33
|
+
|
|
34
|
+
# ── Stop: no debe re-lanzar el turno en bucle (regresión CLAUDE_CODE_STOP_HOOK_BLOCK_CAP) ──
|
|
35
|
+
# En 'Stop' inyectar additionalContext = re-prompt (bloquea el cierre). El warning es solo un
|
|
36
|
+
# nudge mientras se trabaja → en 'Stop' NO debe inyectar (si no, bucle hasta el tope de 9).
|
|
37
|
+
sidW="sessW"; echo "{\"remaining_pct\":30,\"used_pct\":70,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sidW.json"
|
|
38
|
+
PW="$TMP/proj-warn-stop"; mkdir -p "$PW/.claude/state" "$PW/.claude/config"
|
|
39
|
+
cp "$TMP/proj/.claude/state/build-state.json" "$PW/.claude/state/build-state.json"
|
|
40
|
+
echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$PW/.claude/config/build-config.json"
|
|
41
|
+
outW="$(echo "{\"session_id\":\"$sidW\",\"hook_event_name\":\"Stop\"}" \
|
|
42
|
+
| CLAUDE_PROJECT_DIR="$PW" bash "$ROOT/hooks/build/context-monitor.sh")"
|
|
43
|
+
echo "$outW" | grep -q 'additionalContext' \
|
|
44
|
+
&& { echo "FAIL warning+Stop inyecta (re-prompt → bucle)"; fail=1; } \
|
|
45
|
+
|| echo "OK warning+Stop no inyecta"
|
|
46
|
+
# warning en PostToolUse (trabajando) SÍ debe seguir avisando (regresión: no romper el nudge útil).
|
|
47
|
+
outWP="$(echo "{\"session_id\":\"$sidW\",\"hook_event_name\":\"PostToolUse\"}" \
|
|
48
|
+
| CLAUDE_PROJECT_DIR="$PW" bash "$ROOT/hooks/build/context-monitor.sh")"
|
|
49
|
+
echo "$outWP" | grep -q 'additionalContext' \
|
|
50
|
+
&& echo "OK warning+PostToolUse sigue avisando" \
|
|
51
|
+
|| { echo "FAIL warning+PostToolUse dejó de avisar"; fail=1; }
|
|
52
|
+
|
|
53
|
+
# critical en 'Stop': inyecta como MUCHO una vez (la transición que graba el handoff), luego calla.
|
|
54
|
+
sidC="sessC"; echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sidC.json"
|
|
55
|
+
PC="$TMP/proj-crit-stop"; mkdir -p "$PC/.claude/state" "$PC/.claude/config"
|
|
56
|
+
cp "$TMP/proj/.claude/state/build-state.json" "$PC/.claude/state/build-state.json"
|
|
57
|
+
python3 -c "import json;p='$PC/.claude/state/build-state.json';d=json.load(open(p));d['active_slice'].pop('session_continuity',None);json.dump(d,open(p,'w'))"
|
|
58
|
+
echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$PC/.claude/config/build-config.json"
|
|
59
|
+
outC1="$(echo "{\"session_id\":\"$sidC\",\"hook_event_name\":\"Stop\"}" \
|
|
60
|
+
| CLAUDE_PROJECT_DIR="$PC" bash "$ROOT/hooks/build/context-monitor.sh")"
|
|
61
|
+
echo "$outC1" | grep -q 'additionalContext' \
|
|
62
|
+
&& echo "OK critical+Stop inyecta la transición (1ª vez)" \
|
|
63
|
+
|| { echo "FAIL critical+Stop no inyecta la transición"; fail=1; }
|
|
64
|
+
outC2="$(echo "{\"session_id\":\"$sidC\",\"hook_event_name\":\"Stop\"}" \
|
|
65
|
+
| CLAUDE_PROJECT_DIR="$PC" bash "$ROOT/hooks/build/context-monitor.sh")"
|
|
66
|
+
echo "$outC2" | grep -q 'additionalContext' \
|
|
67
|
+
&& { echo "FAIL critical+Stop re-inyecta (bucle)"; fail=1; } \
|
|
68
|
+
|| echo "OK critical+Stop NO re-inyecta (bucle roto)"
|
|
69
|
+
|
|
70
|
+
# load-build-state (SessionStart): reset once-per-SESSION del guard critical_recorded.
|
|
71
|
+
# Sesión NUEVA (last_session distinto del session_id del payload) -> reabre el guard y
|
|
72
|
+
# persiste el session_id actual.
|
|
73
|
+
PROJ="$TMP/proj-lbs"; mkdir -p "$PROJ/.claude/state"
|
|
74
|
+
cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
|
|
75
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
76
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
|
|
77
|
+
"gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
|
|
78
|
+
"progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"old"}},
|
|
79
|
+
"history":[],"releases":[]}
|
|
80
|
+
JSON
|
|
81
|
+
echo '{"session_id":"new"}' | CLAUDE_PROJECT_DIR="$PROJ" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
|
|
82
|
+
cr="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
|
|
83
|
+
ls_="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['last_session'])")"
|
|
84
|
+
[ "$cr" = False ] && echo "OK load-build-state resetea critical_recorded en sesión nueva" || { echo "FAIL critical_recorded no se reseteó ($cr)"; fail=1; }
|
|
85
|
+
[ "$ls_" = new ] && echo "OK load-build-state persiste last_session nuevo" || { echo "FAIL last_session no persistió ($ls_)"; fail=1; }
|
|
86
|
+
|
|
87
|
+
# Sesión IDÉNTICA (last_session == session_id del payload) -> NO toca critical_recorded.
|
|
88
|
+
PROJ2="$TMP/proj-lbs-same"; mkdir -p "$PROJ2/.claude/state"
|
|
89
|
+
cat > "$PROJ2/.claude/state/build-state.json" <<'JSON'
|
|
90
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
91
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
|
|
92
|
+
"gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
|
|
93
|
+
"progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"s1"}},
|
|
94
|
+
"history":[],"releases":[]}
|
|
95
|
+
JSON
|
|
96
|
+
echo '{"session_id":"s1"}' | CLAUDE_PROJECT_DIR="$PROJ2" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
|
|
97
|
+
cr2="$(python3 -c "import json;print(json.load(open('$PROJ2/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
|
|
98
|
+
[ "$cr2" = True ] && echo "OK load-build-state NO toca critical_recorded en misma sesión" || { echo "FAIL critical_recorded se tocó en misma sesión ($cr2)"; fail=1; }
|
|
99
|
+
|
|
100
|
+
# wiring: ambos canales cablean context-monitor en PostToolUse/PreCompact/Stop
|
|
101
|
+
grep -q 'PreCompact' "$ROOT/hooks/build-harness.json" && echo "OK plugin PreCompact" || { echo "FAIL plugin PreCompact"; fail=1; }
|
|
102
|
+
grep -q 'context-monitor.sh' "$ROOT/hooks/build-harness.json" && echo "OK plugin monitor" || { echo "FAIL plugin monitor"; fail=1; }
|
|
103
|
+
grep -q "PreCompact" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli PreCompact" || { echo "FAIL cli PreCompact"; fail=1; }
|
|
104
|
+
grep -q "context-monitor.sh" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli monitor" || { echo "FAIL cli monitor"; fail=1; }
|
|
105
|
+
|
|
106
|
+
exit $fail
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
ROOT="$(git rev-parse --show-toplevel)"; fail=0
|
|
4
|
+
run() { echo "$1" | python3 "$ROOT/scripts/lib/front-plan.py"; }
|
|
5
|
+
|
|
6
|
+
out="$(run '[{"epica":"EP-001","layer":"foundational","files_scope":["src/**"]},
|
|
7
|
+
{"epica":"EP-002","layer":"business","files_scope":["src/a/**"]},
|
|
8
|
+
{"epica":"EP-003","layer":"business","files_scope":["src/b/**"]},
|
|
9
|
+
{"epica":"EP-004","layer":"business","files_scope":["src/a/x.ts"]}]')"
|
|
10
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
11
|
+
assert d['excluded_foundational']==['EP-001'], d
|
|
12
|
+
assert set(d['selected'])=={'EP-002','EP-003'}, d
|
|
13
|
+
assert [m['epica'] for m in d['serialized']]==['EP-004'], d
|
|
14
|
+
print('OK front-plan disjunción')" || { echo "FAIL front-plan disjunción"; fail=1; }
|
|
15
|
+
|
|
16
|
+
for bad in 'null' '[1,2,3]' '"abc"' '{"a":1}'; do
|
|
17
|
+
if out="$(echo "$bad" | python3 "$ROOT/scripts/lib/front-plan.py" 2>/dev/null)" \
|
|
18
|
+
&& echo "$out" | python3 -c "import json,sys;assert json.load(sys.stdin)['selected']==[]" 2>/dev/null; then
|
|
19
|
+
echo "OK front-plan fail-open ($bad)"; else echo "FAIL front-plan fail-open ($bad)"; fail=1; fi
|
|
20
|
+
done
|
|
21
|
+
|
|
22
|
+
out="$(echo '[{"epica":"EP-010","layer":"business","files_scope":["*a*.ts"]},
|
|
23
|
+
{"epica":"EP-011","layer":"business","files_scope":["*b*.ts"]}]' | python3 "$ROOT/scripts/lib/front-plan.py")"
|
|
24
|
+
echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
|
|
25
|
+
assert d['selected']==['EP-010'] and [m['epica'] for m in d['serialized']]==['EP-011'], d
|
|
26
|
+
print('OK front-plan mid-wildcard overlap serializado')" || { echo "FAIL mid-wildcard overlap"; fail=1; }
|
|
27
|
+
|
|
28
|
+
SK="$ROOT/skills/managing-parallel-front/SKILL.md"
|
|
29
|
+
{ [ -f "$SK" ] && grep -q 'git worktree add' "$SK" && grep -q 'front-plan.py' "$SK" \
|
|
30
|
+
&& grep -q 'merge_order' "$SK" && grep -q 'foundational' "$SK"; } \
|
|
31
|
+
&& echo "OK skill front" || { echo "FAIL skill front"; fail=1; }
|
|
32
|
+
|
|
33
|
+
{ [ -f "$ROOT/commands/build/front.md" ] && grep -q 'managing-parallel-front' "$ROOT/commands/build/front.md"; } \
|
|
34
|
+
&& echo "OK comando front" || { echo "FAIL comando front"; fail=1; }
|
|
35
|
+
grep -q 'files_scope' "$ROOT/skills/building-a-slice/references/dor.md" && echo "OK dor files_scope" || { echo "FAIL dor files_scope"; fail=1; }
|
|
36
|
+
grep -q 'parallel_front' "$ROOT/agents/build/build-orchestrator.md" && echo "OK orquestador front" || { echo "FAIL orquestador front"; fail=1; }
|
|
37
|
+
|
|
38
|
+
exit $fail
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-install.sh — smoke test del CANAL CLI de instalación (init/update).
|
|
3
|
+
# Reproduce el bug real: el instalador solo copiaba *.sh de hooks/build/ (sin
|
|
4
|
+
# recursar en lib/) y nunca creaba .claude/scripts/, dejando muertos en un
|
|
5
|
+
# install real los 3 archivos v0.8 (reconcile-build-state.py, state-io.sh,
|
|
6
|
+
# front-plan.py) aunque el smoke suite "en el repo" no lo detectara (los
|
|
7
|
+
# archivos ya existen ahí, fuera del flujo de instalación).
|
|
8
|
+
set -uo pipefail
|
|
9
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
10
|
+
fail=0
|
|
11
|
+
|
|
12
|
+
# 1) Build fresco del CLI compilado (dist/cli.js) — el test corre contra el artefacto publicado.
|
|
13
|
+
if [ ! -f "$ROOT/dist/cli.js" ]; then
|
|
14
|
+
echo "ℹ dist/cli.js no existe — corriendo npm run build"
|
|
15
|
+
(cd "$ROOT" && npm run build) >/dev/null 2>&1
|
|
16
|
+
fi
|
|
17
|
+
if [ ! -f "$ROOT/dist/cli.js" ]; then
|
|
18
|
+
echo "FAIL install: npm run build no produjo dist/cli.js"
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
TMP="$(mktemp -d)"
|
|
23
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
24
|
+
|
|
25
|
+
# 2) `init` no interactivo, modo copy (para no depender de symlinks al paquete),
|
|
26
|
+
# sin exigir openspec/python3/git en el runner (--skip-doctor).
|
|
27
|
+
node "$ROOT/dist/cli.js" init "$TMP" \
|
|
28
|
+
--copy \
|
|
29
|
+
--skip-doctor \
|
|
30
|
+
--yes \
|
|
31
|
+
--stack "" \
|
|
32
|
+
--pkg-manager npm \
|
|
33
|
+
--runtime ">=18.18" \
|
|
34
|
+
--prd-path "docs/01-prd/x.md#req" \
|
|
35
|
+
>"$TMP/.init.log" 2>&1
|
|
36
|
+
rc=$?
|
|
37
|
+
if [ $rc -ne 0 ]; then
|
|
38
|
+
echo "FAIL install: \`trycore-build init\` salió con código $rc"
|
|
39
|
+
cat "$TMP/.init.log"
|
|
40
|
+
fail=1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
check() { # <ruta relativa a .claude> <descripción>
|
|
44
|
+
local rel="$1"
|
|
45
|
+
local p="$TMP/.claude/$rel"
|
|
46
|
+
if [ -f "$p" ]; then
|
|
47
|
+
echo "OK install: .claude/$rel"
|
|
48
|
+
else
|
|
49
|
+
echo "FAIL install: .claude/$rel NO existe"
|
|
50
|
+
fail=1
|
|
51
|
+
fi
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
check "hooks/build/reconcile-build-state.py"
|
|
55
|
+
check "hooks/build/lib/state-io.sh"
|
|
56
|
+
check "hooks/build/context-monitor.sh"
|
|
57
|
+
check "hooks/build/statusline-bridge.sh"
|
|
58
|
+
check "scripts/lib/front-plan.py"
|
|
59
|
+
|
|
60
|
+
# 3) Bit ejecutable en los .py/.sh copiados (modo copy no preserva +x por defecto — el
|
|
61
|
+
# instalador debe forzarlo con chmodExec tras copiar).
|
|
62
|
+
checkexec() {
|
|
63
|
+
local rel="$1"
|
|
64
|
+
local p="$TMP/.claude/$rel"
|
|
65
|
+
if [ -f "$p" ] && [ -x "$p" ]; then
|
|
66
|
+
echo "OK install: .claude/$rel es ejecutable"
|
|
67
|
+
else
|
|
68
|
+
echo "FAIL install: .claude/$rel NO es ejecutable"
|
|
69
|
+
fail=1
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
checkexec "hooks/build/reconcile-build-state.py"
|
|
73
|
+
checkexec "hooks/build/lib/state-io.sh"
|
|
74
|
+
checkexec "scripts/lib/front-plan.py"
|
|
75
|
+
|
|
76
|
+
# 4) `update` (canal de refresco tras `npm i` del paquete) también debe dejarlos — re-correr
|
|
77
|
+
# sobre la misma instalación no debe romper nada ni perder archivos.
|
|
78
|
+
node "$ROOT/dist/cli.js" update "$TMP" --copy --skip-doctor >"$TMP/.update.log" 2>&1
|
|
79
|
+
rc=$?
|
|
80
|
+
if [ $rc -ne 0 ]; then
|
|
81
|
+
echo "FAIL install: \`trycore-build update\` salió con código $rc"
|
|
82
|
+
cat "$TMP/.update.log"
|
|
83
|
+
fail=1
|
|
84
|
+
fi
|
|
85
|
+
check "hooks/build/reconcile-build-state.py"
|
|
86
|
+
check "hooks/build/lib/state-io.sh"
|
|
87
|
+
check "scripts/lib/front-plan.py"
|
|
88
|
+
|
|
89
|
+
exit $fail
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
4
|
+
source "$ROOT/hooks/build/lib/state-io.sh" 2>/dev/null || { echo "FAIL no existe state-io.sh"; exit 1; }
|
|
5
|
+
fail=0
|
|
6
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
7
|
+
|
|
8
|
+
# config_get lee clave punteada con default
|
|
9
|
+
echo '{"context":{"warning_pct":40}}' > "$TMP/cfg.json"
|
|
10
|
+
got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get context.warning_pct 35)"
|
|
11
|
+
[ "$got" = 40 ] && echo "OK config_get valor" || { echo "FAIL config_get valor ($got)"; fail=1; }
|
|
12
|
+
got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get context.critical_pct 25)"
|
|
13
|
+
[ "$got" = 25 ] && echo "OK config_get default" || { echo "FAIL config_get default ($got)"; fail=1; }
|
|
14
|
+
|
|
15
|
+
# state_atomic_patch muta y deja JSON válido
|
|
16
|
+
echo '{"version":"1.0","n":1}' > "$TMP/s.json"
|
|
17
|
+
state_atomic_patch "$TMP/s.json" 'd["n"]=2'
|
|
18
|
+
got="$(python3 -c "import json;print(json.load(open('$TMP/s.json'))['n'])")"
|
|
19
|
+
[ "$got" = 2 ] && echo "OK atomic_patch" || { echo "FAIL atomic_patch ($got)"; fail=1; }
|
|
20
|
+
|
|
21
|
+
# reconcile-build-state: degrada passing sin evidencia, conserva con evidencia, fail-open
|
|
22
|
+
cat > "$TMP/state.json" <<'JSON'
|
|
23
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
24
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
|
|
25
|
+
"gates":{"dor":true,"tdd":true,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
|
|
26
|
+
"wiring_checklist":[{"id":"HU-001-AC1","kind":"hu_ac","ref":"HU-001#1","status":"passing","evidence":""},
|
|
27
|
+
{"id":"HU-001-AC2","kind":"hu_ac","ref":"HU-001#2","status":"passing","evidence":"pytest ok"}]},
|
|
28
|
+
"history":[],"releases":[]}
|
|
29
|
+
JSON
|
|
30
|
+
python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/state.json" 2>/dev/null
|
|
31
|
+
st1="$(python3 -c "import json;d=json.load(open('$TMP/state.json'));print(d['active_slice']['wiring_checklist'][0]['status'])")"
|
|
32
|
+
st2="$(python3 -c "import json;d=json.load(open('$TMP/state.json'));print(d['active_slice']['wiring_checklist'][1]['status'])")"
|
|
33
|
+
[ "$st1" = failing ] && echo "OK reconcile degrada sin evidencia" || { echo "FAIL degrada ($st1)"; fail=1; }
|
|
34
|
+
[ "$st2" = passing ] && echo "OK reconcile conserva con evidencia" || { echo "FAIL conserva ($st2)"; fail=1; }
|
|
35
|
+
# fail-open: archivo corrupto no rompe
|
|
36
|
+
echo 'no json' > "$TMP/bad.json"
|
|
37
|
+
python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/bad.json" 2>/dev/null; echo "OK reconcile fail-open (rc=$?)"
|
|
38
|
+
|
|
39
|
+
# fail-open: wiring_checklist malformado (item no-dict) no debe crashear ni corromper el archivo
|
|
40
|
+
cat > "$TMP/mal.json" <<'JSON'
|
|
41
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
42
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
|
|
43
|
+
"gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
|
|
44
|
+
"wiring_checklist":["not-a-dict"]},
|
|
45
|
+
"history":[],"releases":[]}
|
|
46
|
+
JSON
|
|
47
|
+
if python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/mal.json" 2>/dev/null; then
|
|
48
|
+
python3 -c "import json;json.load(open('$TMP/mal.json'))" 2>/dev/null && echo "OK reconcile fail-open wiring malformado" || { echo "FAIL mal json corrupto"; fail=1; }
|
|
49
|
+
else echo "FAIL reconcile crashed on malformed wiring"; fail=1; fi
|
|
50
|
+
|
|
51
|
+
# SessionStart integra reconciliador
|
|
52
|
+
grep -q 'reconcile-build-state.py' "$ROOT/hooks/build/load-build-state.sh" && echo "OK sessionstart reconcilia" || { echo "FAIL sessionstart no reconcilia"; fail=1; }
|
|
53
|
+
grep -q 'resume_hint' "$ROOT/hooks/build/load-build-state.sh" && echo "OK sessionstart imprime hint" || { echo "FAIL sessionstart sin hint"; fail=1; }
|
|
54
|
+
exit $fail
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-schema.sh — valida build-state.schema.json contra ejemplos válidos/ inválidos.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
5
|
+
SCHEMA="$ROOT/state/build-state.schema.json"
|
|
6
|
+
fail=0
|
|
7
|
+
|
|
8
|
+
check() { # <descripción> <esperado: pass|fail> <json-file>
|
|
9
|
+
python3 - "$SCHEMA" "$3" <<'PY'
|
|
10
|
+
import json,sys
|
|
11
|
+
try:
|
|
12
|
+
import jsonschema
|
|
13
|
+
except ImportError:
|
|
14
|
+
print("SKIP: jsonschema no instalado"); sys.exit(2)
|
|
15
|
+
schema=json.load(open(sys.argv[1])); data=json.load(open(sys.argv[2]))
|
|
16
|
+
from jsonschema import Draft202012Validator
|
|
17
|
+
errs=sorted(Draft202012Validator(schema).iter_errors(data), key=str)
|
|
18
|
+
sys.exit(1 if errs else 0)
|
|
19
|
+
PY
|
|
20
|
+
local rc=$?
|
|
21
|
+
if [ "$rc" = 2 ]; then echo "SKIP $1"; return; fi
|
|
22
|
+
if { [ "$2" = pass ] && [ "$rc" = 0 ]; } || { [ "$2" = fail ] && [ "$rc" = 1 ]; }; then
|
|
23
|
+
echo "OK $1"
|
|
24
|
+
else echo "FAIL $1 (rc=$rc)"; fail=1; fi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
28
|
+
cat > "$TMP/valid.json" <<'JSON'
|
|
29
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
30
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"add-login",
|
|
31
|
+
"branch":"feature/login","phase":"change","layer":"business","files_scope":["src/auth/**"],
|
|
32
|
+
"gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"test",
|
|
33
|
+
"session_continuity":{"last_session":"s1","stopped_at":null,"resume_hint":"cablear AC2","critical_recorded":false,"auto_continue":false}},
|
|
34
|
+
"history":[],"releases":[],
|
|
35
|
+
"parallel_front":{"status":"active","opened_at":"2026-07-03T00:00:00Z","updated_by":"test",
|
|
36
|
+
"members":[{"epica":"EP-002","worktree":{"path":".wt/ep-002","branch":"feature/ep-002","created_at":"2026-07-03T00:00:00Z"},
|
|
37
|
+
"files_scope":["src/reports/**"],"journey_smoke":false,"merge_status":"pending"}],
|
|
38
|
+
"merge_order":["EP-002"]}}
|
|
39
|
+
JSON
|
|
40
|
+
cat > "$TMP/bad-layer.json" <<'JSON'
|
|
41
|
+
{"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
|
|
42
|
+
"active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"change",
|
|
43
|
+
"layer":"WRONG","gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t"},
|
|
44
|
+
"history":[],"releases":[]}
|
|
45
|
+
JSON
|
|
46
|
+
check "estado válido con campos nuevos" pass "$TMP/valid.json"
|
|
47
|
+
check "layer inválido rechazado" fail "$TMP/bad-layer.json"
|
|
48
|
+
|
|
49
|
+
CFG="$ROOT/config/build-config.template.json"
|
|
50
|
+
if [ -f "$CFG" ] && python3 -c "import json,sys; d=json.load(open('$CFG'))['context']; assert d['warning_pct']==35 and d['critical_pct']==25 and d['auto_checkpoint'] is False" 2>/dev/null; then
|
|
51
|
+
echo "OK build-config.template.json con defaults"
|
|
52
|
+
else echo "FAIL build-config.template.json con defaults"; fail=1; fi
|
|
53
|
+
|
|
54
|
+
CMD="$ROOT/commands/build/resume.md"
|
|
55
|
+
{ [ -f "$CMD" ] && grep -q 'reconcile-build-state.py' "$CMD" && grep -q 'session_continuity' "$CMD"; } \
|
|
56
|
+
&& echo "OK comando resume" || { echo "FAIL comando resume"; fail=1; }
|
|
57
|
+
|
|
58
|
+
exit $fail
|
|
@@ -16,6 +16,8 @@ la épica; lo de abajo aplica a la épica y a **cada HU** que cubre (`hus[]`). L
|
|
|
16
16
|
- [ ] **Fuente de diseño identificada (slices con UI)**: la fuente visual de verdad del slice
|
|
17
17
|
(el `DESIGN_SOURCE` del dominio) está declarada y confirmada (`design_source.confirmed`), y este
|
|
18
18
|
slice apunta a la(s) pantalla(s) equivalente(s). No se construye UI fuera de la fuente declarada.
|
|
19
|
+
- [ ] **Clasificación `layer`**: `foundational` (auth/datos/design-system/arquitectura base) | `business`. Se escribe en `active_slice.layer`. Gatea el front paralelo (foundational nunca en paralelo).
|
|
20
|
+
- [ ] **`files_scope`**: globs de los archivos que la épica tocará (p.ej. `src/reports/**`). Fuente de la disjunción inter-épica. Se escribe en `active_slice.files_scope`.
|
|
19
21
|
|
|
20
22
|
**Si todo ✓** → `dor-dod-gatekeeper` abre `active_slice` en `build-state.json` con `epica`, `hus[]`,
|
|
21
23
|
`phase: dor`, `gates.dor: true` y el resto en `false` —incluido **`wiring_verified: false`**—
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: managing-parallel-front
|
|
3
|
+
description: Use when building multiple NON-foundational, file-disjoint epics in parallel via git worktrees. Coordinates the parallel_front in build-state.json (selection, worktrees, deterministic merge order with re-smoke). Never parallelizes foundational epics or overlapping file scopes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gestionar el front paralelo inter-épica (outer-loop)
|
|
7
|
+
|
|
8
|
+
**Invariante:** el paralelismo es outer-loop. Cada worktree es un checkout aislado con su
|
|
9
|
+
**propio** `build-state.json` y su `active_slice` singular (el inner loop no cambia).
|
|
10
|
+
|
|
11
|
+
## Precondiciones (compuertas)
|
|
12
|
+
- `scaffold.confirmed == true`.
|
|
13
|
+
- **G1 Fundacionales primero:** ninguna épica `layer=foundational` abierta. Si aparece una,
|
|
14
|
+
poner `parallel_front.status="draining"` (terminar en curso, no admitir nuevas) antes de abrirla.
|
|
15
|
+
|
|
16
|
+
## Procedimiento
|
|
17
|
+
1. **Reunir candidatas** no fundacionales listas (DoR pasado), cada una con `layer` y `files_scope`.
|
|
18
|
+
2. **Seleccionar el conjunto disjunto** (G2):
|
|
19
|
+
`echo "$CANDS" | python3 .claude/scripts/lib/front-plan.py`
|
|
20
|
+
→ `selected` van al front; `serialized` esperan (construir secuencial después);
|
|
21
|
+
`excluded_foundational` nunca en paralelo.
|
|
22
|
+
3. **Abrir un worktree por épica seleccionada:**
|
|
23
|
+
`git worktree add ".wt/<epica>" -b feature/<slug>` (rama por `1 épica = 1 rama = 1 PR`).
|
|
24
|
+
Registrar el miembro en `parallel_front.members[]` (`merge_status:"pending"`).
|
|
25
|
+
4. **Construir cada worktree** con la skill `building-a-slice` (inner loop normal, en su cwd).
|
|
26
|
+
Cada uno mantiene su `journey_smoke` verde localmente.
|
|
27
|
+
5. **Coordinar merge (G3)** en `merge_order` (determinista: por orden de épica):
|
|
28
|
+
- Merge del PR; tras cada merge, **re-smoke del journey completo** en el árbol principal.
|
|
29
|
+
- Conflicto → `merge_status:"conflict"`, serializar la perdedora (rebase + re-correr sus gates).
|
|
30
|
+
- Éxito → `merge_status:"merged"`; `git worktree remove`.
|
|
31
|
+
6. **Cerrar el front** cuando todos `merged`: `parallel_front=null`.
|
|
32
|
+
|
|
33
|
+
## Reglas duras
|
|
34
|
+
- Nunca escritores paralelos sobre el mismo árbol (por eso worktrees).
|
|
35
|
+
- Nunca paralelizar dentro de una épica (preserva la "regla del esqueleto que camina").
|
|
36
|
+
- Un solape de `files_scope` no detectado que cause conflicto de merge → el re-smoke (G3) lo caza.
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"type": "array",
|
|
32
32
|
"description": "Release Gates (outer loop). Una entrada por línea de release del Story Map auditada en bloque.",
|
|
33
33
|
"items": { "$ref": "#/$defs/release" }
|
|
34
|
+
},
|
|
35
|
+
"parallel_front": {
|
|
36
|
+
"description": "Coordinación outer-loop de worktrees inter-épica (C). null = modo secuencial normal.",
|
|
37
|
+
"oneOf": [ { "type": "null" }, { "$ref": "#/$defs/parallel_front" } ]
|
|
34
38
|
}
|
|
35
39
|
},
|
|
36
40
|
"$defs": {
|
|
@@ -75,6 +79,7 @@
|
|
|
75
79
|
},
|
|
76
80
|
"openspec_change": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
77
81
|
"branch": { "type": "string", "pattern": "^(feature|fix|chore)/[a-z0-9._-]+$" },
|
|
82
|
+
"branch_drift": { "type": "string", "description": "Rama git real cuando difiere de branch (lo escribe el reconciliador)." },
|
|
78
83
|
"phase": {
|
|
79
84
|
"type": "string",
|
|
80
85
|
"enum": ["dor", "change", "red", "green", "refactor", "smoke", "api", "data", "dod", "pr", "archived"]
|
|
@@ -151,6 +156,19 @@
|
|
|
151
156
|
"journey_smoke": { "type": "boolean", "description": "El backbone-hasta-este-sub-slice camina end-to-end." }
|
|
152
157
|
}
|
|
153
158
|
}
|
|
159
|
+
},
|
|
160
|
+
"layer": { "type": "string", "enum": ["foundational", "business"], "default": "business", "description": "Clasificación (taxonomía existente del arnés): foundational (auth/datos/design-system/arquitectura base) nunca va en paralelo; business = épica de negocio (paralelizable si es disjunta). Gatea el front paralelo (C)." },
|
|
161
|
+
"files_scope": { "type": "array", "items": { "type": "string" }, "description": "Globs de archivos que la épica toca. Fuente de la disjunción inter-épica (G2)." },
|
|
162
|
+
"session_continuity": {
|
|
163
|
+
"type": "object", "additionalProperties": false,
|
|
164
|
+
"description": "Handoff de sesión (A): permite resume sin pérdida tras presión de contexto.",
|
|
165
|
+
"properties": {
|
|
166
|
+
"last_session": { "type": ["string", "null"] },
|
|
167
|
+
"stopped_at": { "type": ["string", "null"], "description": "p.ej. 'context exhaustion at 24% (ISO)'." },
|
|
168
|
+
"resume_hint": { "type": ["string", "null"], "description": "Siguiente acción concisa." },
|
|
169
|
+
"critical_recorded": { "type": "boolean", "description": "Guard once-per-session del auto-handoff." },
|
|
170
|
+
"auto_continue": { "type": "boolean", "description": "true cuando context.auto_checkpoint disparó continuación automática (§4.4)." }
|
|
171
|
+
}
|
|
154
172
|
}
|
|
155
173
|
}
|
|
156
174
|
},
|
|
@@ -184,6 +202,38 @@
|
|
|
184
202
|
"created_at": { "type": "string", "format": "date-time" },
|
|
185
203
|
"updated_by": { "type": "string" }
|
|
186
204
|
}
|
|
205
|
+
},
|
|
206
|
+
"parallel_front": {
|
|
207
|
+
"type": "object", "additionalProperties": false,
|
|
208
|
+
"required": ["status", "opened_at", "updated_by", "members", "merge_order"],
|
|
209
|
+
"properties": {
|
|
210
|
+
"status": { "type": "string", "enum": ["active", "draining"] },
|
|
211
|
+
"opened_at": { "type": "string", "format": "date-time" },
|
|
212
|
+
"updated_by": { "type": "string" },
|
|
213
|
+
"members": {
|
|
214
|
+
"type": "array",
|
|
215
|
+
"items": {
|
|
216
|
+
"type": "object", "additionalProperties": false,
|
|
217
|
+
"required": ["epica", "worktree", "files_scope", "merge_status"],
|
|
218
|
+
"properties": {
|
|
219
|
+
"epica": { "type": "string", "pattern": "^EP-[0-9]{3}$" },
|
|
220
|
+
"worktree": {
|
|
221
|
+
"type": "object", "additionalProperties": false,
|
|
222
|
+
"required": ["path", "branch", "created_at"],
|
|
223
|
+
"properties": {
|
|
224
|
+
"path": { "type": "string" },
|
|
225
|
+
"branch": { "type": "string", "pattern": "^(feature|fix|chore)/[a-z0-9._-]+$" },
|
|
226
|
+
"created_at": { "type": "string", "format": "date-time" }
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
"files_scope": { "type": "array", "items": { "type": "string" } },
|
|
230
|
+
"journey_smoke": { "type": "boolean" },
|
|
231
|
+
"merge_status": { "type": "string", "enum": ["pending", "merged", "conflict"] }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"merge_order": { "type": "array", "items": { "type": "string", "pattern": "^EP-[0-9]{3}$" } }
|
|
236
|
+
}
|
|
187
237
|
}
|
|
188
238
|
}
|
|
189
239
|
}
|