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:
+71
-13
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user