feat(ops): add stable record_id column to FEATURE-MATRIX

Make matrix_states / docs apply / evidence gates key by kebab-case
record_id (module name as transitional fallback). Wire docs project
for MATRIX with EVIDENCE_REQUIRED on done. Keep all status values and
historical paragraphs unchanged; reject rename/duplicate IDs.
This commit is contained in:
WeiCHEN
2026-09-23 17:35:25 +08:00
parent 713b7cbe6f
commit 77bdbdbc74
7 changed files with 323 additions and 49 deletions
+71 -13
View File
@@ -44,7 +44,7 @@ DEFAULT_POLICY = {
},
'.doc/status/FEATURE-MATRIX.md': {
'evidence_for_done': True,
'header': '| 模块 |',
'header': '| record_id | 模块 |',
},
},
'reserved_record_ids': ['holder', 'idle', 'Cursor', 'Codex', '-'],
@@ -1045,7 +1045,11 @@ def matrix_states(content):
continue
cells = table_cells(line)
if status_index is None:
if cells[0].casefold() not in ('模块', 'module', 'feature'):
if not cells:
continue
first = cells[0].casefold()
# record_id/id first → stable key in cells[0]; legacy 模块/module/feature → key is module name.
if first not in ('record_id', 'id', '模块', 'module', 'feature'):
continue
labels = [c.strip('*').casefold() for c in cells]
columns = [i for i,c in enumerate(labels) if c in ('状态','state','status')]
@@ -1066,6 +1070,50 @@ def matrix_states(content):
return states
def matrix_module_states(content):
"""Map module display name → status for evidence gates across record_id migrations."""
states, status_index, module_index, width = {}, None, None, None
for line in content.splitlines():
if not line.lstrip(' ').startswith('|'):
if status_index is not None:
break
continue
cells = table_cells(line)
if status_index is None:
if not cells:
continue
first = cells[0].casefold()
if first not in ('record_id', 'id', '模块', 'module', 'feature'):
continue
labels = [c.strip('*').casefold() for c in cells]
columns = [i for i, c in enumerate(labels) if c in ('状态', 'state', 'status')]
if len(columns) != 1:
deny('INVALID_DOCUMENT', 'Matrix needs one explicit status column')
module_cols = [i for i, c in enumerate(labels) if c in ('模块', 'module', 'feature')]
if first in ('record_id', 'id'):
if len(module_cols) != 1:
deny('INVALID_DOCUMENT', 'Matrix with record_id needs one module column')
module_index = module_cols[0]
else:
module_index = 0
status_index, width = columns[0], len(cells)
continue
if all(re.fullmatch(r':?-+:?', cell.replace(' ', '')) for cell in cells):
continue
if len(cells) != width:
deny('INVALID_DOCUMENT', 'Matrix records must match its columns')
module = cells[module_index]
if module in states:
deny('INVALID_DOCUMENT', 'Matrix module names must be unique')
value = cells[status_index]
if value not in ('done', 'partial', 'planned', 'deferred', 'blocked', 'removed'):
deny('INVALID_STATE', 'Matrix status must use an unformatted canonical state')
states[module] = value
if status_index is None:
deny('INVALID_DOCUMENT', 'Matrix table header is missing')
return states
def docs_snapshot(repo, args):
if args.file not in SHARED_DOCS:
deny('PATH_OUT_OF_SCOPE', 'Only the two shared governance documents are managed')
@@ -1130,10 +1178,12 @@ def docs_apply(repo, args):
if slot(replacement) != slot(before[filename]):
deny('PATH_OUT_OF_SCOPE', 'Document admin cannot overwrite integration slot ownership')
# A layout update cannot bypass the feature-completion evidence rule.
# Compare by module display name so additive record_id migration does not
# look like newly promoted done rows (keys would otherwise diverge).
if DOC_RULES.get(filename, {}).get('evidence_for_done') or filename.endswith('FEATURE-MATRIX.md'):
old_states = matrix_states(before[filename])
new_states = matrix_states(replacement)
if any(value == 'done' and old_states.get(key) != 'done' for key,value in new_states.items()):
old_states = matrix_module_states(before[filename])
new_states = matrix_module_states(replacement)
if any(value == 'done' and old_states.get(key) != 'done' for key, value in new_states.items()):
deny('EVIDENCE_REQUIRED', 'Change capability completion using a reviewed row update')
after[filename] = replacement
continue
@@ -1142,13 +1192,16 @@ def docs_apply(repo, args):
cells = table_cells(replacement)
if record_id not in cells[:2]:
deny('INVALID_REQUEST', 'Replacement must preserve the record id')
if filename.endswith('FEATURE-MATRIX.md') and 'done' in cells and matrix_states(before[filename]).get(record_id) != 'done':
evidence = change.get('evidence', [])
if 'docs:approve' not in json.loads(task['capabilities']) or not isinstance(evidence, list) or not evidence or any(not isinstance(e, str) for e in evidence):
deny('EVIDENCE_REQUIRED', 'Capability completion requires approval capability and committed evidence')
for evidence_file in evidence:
safe_path(repo.current, evidence_file)
git(repo.current, 'cat-file', '-e', 'HEAD:' + relative(evidence_file))
if filename.endswith('FEATURE-MATRIX.md'):
if cells[0] != record_id:
deny('INVALID_REQUEST', 'FEATURE-MATRIX replacement must keep record_id in the first column')
if 'done' in cells and matrix_states(before[filename]).get(record_id) != 'done':
evidence = change.get('evidence', [])
if 'docs:approve' not in json.loads(task['capabilities']) or not isinstance(evidence, list) or not evidence or any(not isinstance(e, str) for e in evidence):
deny('EVIDENCE_REQUIRED', 'Capability completion requires approval capability and committed evidence')
for evidence_file in evidence:
safe_path(repo.current, evidence_file)
git(repo.current, 'cat-file', '-e', 'HEAD:' + relative(evidence_file))
lines = after[filename].splitlines()
live = doc_rows(after[filename], record_id)
if live:
@@ -1156,7 +1209,7 @@ def docs_apply(repo, args):
else:
header = DOC_RULES.get(filename, {}).get('header')
if not header:
header = '| owner | task |' if filename.endswith('CURRENT.md') else '| 模块 |'
header = '| owner | task |' if filename.endswith('CURRENT.md') else '| record_id | 模块 |'
positions = [i for i, line in enumerate(lines) if line.startswith(header)]
if len(positions) != 1:
deny('INVALID_DOCUMENT', 'Target table header is absent or ambiguous')
@@ -1749,6 +1802,11 @@ def docs_project(repo, args):
replacement = w4.build_projection_row(
task_id=task['id'], owner=task['owner'], branch=task['branch'],
worktree=worktree_cell, mutex=args.mutex or '-', status=status)
elif filename.endswith('FEATURE-MATRIX.md'):
# MATRIX statuses (done|partial|planned|…) differ from CURRENT projection vocab;
# callers must supply a full row including stable record_id via --replacement.
deny('INVALID_REQUEST',
'FEATURE-MATRIX row projection requires --replacement with full row including record_id')
else:
deny('INVALID_REQUEST', 'Row projection for this file requires --replacement')
change = w4.build_row_projection_change(
+152 -3
View File
@@ -49,7 +49,8 @@ class AgentctlTests(unittest.TestCase):
"# Coordination\n\n| Task | Owner | Branch | State |\n| --- | --- | --- | --- |\n"
+ "\n".join(self.current_rows.values()) + "\n", encoding="utf-8")
(self.root / ".doc/status/FEATURE-MATRIX.md").write_text(
"# Features\n\n| Feature | State |\n| --- | --- |\n| core | planned |\n", encoding="utf-8")
"# Features\n\n| record_id | 模块 | 状态 |\n| --- | --- | --- |\n| core | core | planned |\n",
encoding="utf-8")
self.git("add", ".")
self.git("commit", "-m", "test fixture")
@@ -373,7 +374,7 @@ class AgentctlTests(unittest.TestCase):
before = {name: (task / name).read_bytes() for name in (current, features)}
request = self.docs_request([
self.doc_change(current, "alpha", self.current_rows["alpha"], self.current_rows["alpha"].replace("active", "done")),
self.doc_change(features, "core", "| core | stale |", "| core | done |"),
self.doc_change(features, "core", "| core | core | stale |", "| core | core | done |"),
])
result = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(result.returncode, 0)
@@ -528,11 +529,159 @@ class AgentctlTests(unittest.TestCase):
for indent in range(4):
with self.subTest(indent=indent):
request = self.docs_request([{"file":filename,"record_id":"@document","expected_sha256":snapshot["sha256"],
"replacement":original.replace("| core | planned |", " "*indent+"| core |done|")}], "compact-done-"+str(indent))
"replacement":original.replace("| core | core | planned |", " "*indent+"| core | core |done|")}], "compact-done-"+str(indent))
result = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((task / filename).read_text(), original)
def test_matrix_done_without_evidence_refused(self):
task = self.initialized_task(capabilities=("docs:publish", "docs:approve", "docs:admin"))
filename = ".doc/status/FEATURE-MATRIX.md"
old = "| core | core | planned |"
# Row update to done without evidence list → EVIDENCE_REQUIRED
request = self.docs_request([
self.doc_change(filename, "core", old, "| core | core | done |"),
], "matrix-done-no-evidence")
denied = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("EVIDENCE_REQUIRED", denied.stderr)
self.assertIn("| core | core | planned |", (task / filename).read_text())
# @document rewrite that bumps partial/planned→done without per-row evidence
original = (task / filename).read_text()
snapshot = json.loads(self.cli("docs", "snapshot", "--file", filename, "--record", "@document", cwd=task).stdout)
bumped = original.replace("| core | core | planned |", "| core | core | done |")
request = self.docs_request([{
"file": filename, "record_id": "@document",
"expected_sha256": snapshot["sha256"], "replacement": bumped,
}], "matrix-doc-done-bypass")
denied = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("EVIDENCE_REQUIRED", denied.stderr)
self.assertEqual((task / filename).read_text(), original)
def test_matrix_record_id_layout_migration_without_new_dones(self):
"""Additive record_id column must not trip EVIDENCE_REQUIRED when statuses unchanged."""
task = self.initialized_task(capabilities=("docs:publish", "docs:admin"))
filename = ".doc/status/FEATURE-MATRIX.md"
legacy = (
"# Features\n\n"
"| 模块 | 状态 |\n"
"| --- | --- |\n"
"| core | planned |\n"
"| intent | done |\n"
)
(task / filename).write_text(legacy, encoding="utf-8")
self.git("add", filename, cwd=task)
self.git("commit", "-m", "legacy matrix", cwd=task)
snapshot = json.loads(self.cli("docs", "snapshot", "--file", filename, "--record", "@document", cwd=task).stdout)
migrated = (
"# Features\n\n"
"| record_id | 模块 | 状态 |\n"
"| --- | --- | --- |\n"
"| core | core | planned |\n"
"| intent-l1 | intent | done |\n"
)
request = self.docs_request([{
"file": filename, "record_id": "@document",
"expected_sha256": snapshot["sha256"], "replacement": migrated,
}], "matrix-layout-migrate")
result = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
text = (task / filename).read_text()
self.assertIn("| record_id | 模块 | 状态 |", text)
self.assertIn("| intent-l1 | intent | done |", text)
self.assertIn("| core | core | planned |", text)
def test_matrix_unknown_columns_and_lineage_preserved(self):
task = self.initialized_task(capabilities=("docs:publish", "docs:admin"))
filename = ".doc/status/FEATURE-MATRIX.md"
body = (
"# Features\n\n"
"| record_id | 模块 | 状态 | extra |\n"
"| --- | --- | --- | --- |\n"
"| core | core | planned | keep-me |\n\n"
"## Wave 0 lineage\n\n"
"trailing history must survive layout edits.\n"
)
(task / filename).write_text(body, encoding="utf-8")
self.git("add", filename, cwd=task)
self.git("commit", "-m", "seed matrix lineage", cwd=task)
snapshot = json.loads(self.cli("docs", "snapshot", "--file", filename, "--record", "@document", cwd=task).stdout)
updated = body.replace("| core | core | planned | keep-me |",
"| core | core | planned | keep-me-too |")
request = self.docs_request([{
"file": filename, "record_id": "@document",
"expected_sha256": snapshot["sha256"], "replacement": updated,
}], "matrix-preserve-wave0")
result = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task)
self.assertEqual(result.returncode, 0, result.stderr)
text = (task / filename).read_text()
self.assertIn("## Wave 0 lineage", text)
self.assertIn("trailing history must survive layout edits.", text)
self.assertIn("keep-me-too", text)
self.assertIn("| record_id | 模块 | 状态 | extra |", text)
def test_task_close_does_not_auto_promote_matrix_done(self):
task = self.initialized_task(capabilities=("docs:publish",))
filename = ".doc/status/FEATURE-MATRIX.md"
before = (task / filename).read_text()
self.assertIn("| core | core | planned |", before)
closed = json.loads(self.cli("task", "close", "--id", "alpha", "--status", "done").stdout)
self.assertEqual(closed["status"], "done")
after = (task / filename).read_text()
self.assertEqual(after, before)
self.assertIn("| core | core | planned |", after)
self.assertFalse((task / ".doc/remediation/gap-matrix.md").exists())
def test_matrix_duplicate_or_rename_record_id_rejected(self):
task = self.initialized_task(capabilities=("docs:publish", "docs:admin"))
filename = ".doc/status/FEATURE-MATRIX.md"
# Duplicate record_id in document → INVALID_DOCUMENT / AMBIGUOUS
dup = (
"# Features\n\n"
"| record_id | 模块 | 状态 |\n"
"| --- | --- | --- |\n"
"| core | core | planned |\n"
"| core | other | partial |\n"
)
(task / filename).write_text(dup, encoding="utf-8")
self.git("add", filename, cwd=task)
self.git("commit", "-m", "dup matrix", cwd=task)
snapshot = json.loads(self.cli("docs", "snapshot", "--file", filename, "--record", "@document", cwd=task).stdout)
request = self.docs_request([{
"file": filename, "record_id": "@document",
"expected_sha256": snapshot["sha256"], "replacement": dup,
}], "matrix-dup-apply")
denied = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertTrue(
"INVALID_DOCUMENT" in denied.stderr or "AMBIGUOUS_RECORD" in denied.stderr,
denied.stderr)
# Replacement missing / renaming record_id → INVALID_REQUEST
good = (
"# Features\n\n"
"| record_id | 模块 | 状态 |\n"
"| --- | --- | --- |\n"
"| core | core | planned |\n"
)
(task / filename).write_text(good, encoding="utf-8")
self.git("add", filename, cwd=task)
self.git("commit", "-m", "reset matrix", cwd=task)
old = "| core | core | planned |"
# Renaming record_id while editing under the old id
request = self.docs_request([
self.doc_change(filename, "core", old, "| core-renamed | core | planned |"),
], "matrix-rename-id")
denied = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("INVALID_REQUEST", denied.stderr)
request = self.docs_request([
self.doc_change(filename, "core", old, "| enigmamodule | planned |"),
], "matrix-missing-id")
denied = self.cli("docs", "apply", "--task", "alpha", "--request", request, cwd=task, check=False)
self.assertNotEqual(denied.returncode, 0)
self.assertIn("INVALID_REQUEST", denied.stderr)
def test_validated_linear_commit_cannot_bypass_candidate_entrypoint(self):
task = self.initialized_task(scopes=("src",), capabilities=("git:dev",))
self.cli("hooks", "install")