Skip to content

Commit 7be6e7e

Browse files
committed
feat(absorption): enforce executable absorb with probes for R1-R4 and T2
Require SourceExistence, ProbeNecessity, and enforcementLevel before absorb. Land ProgressReportFastPath, TaskPhaseProjection, ClosedArtifactNoRevive, FormalRerunLightClassify, and FindingObjectLayer with classifiers and tests.
1 parent 2e212d2 commit 7be6e7e

19 files changed

Lines changed: 998 additions & 41 deletions

‎changelogs/unreleased.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
66
## 当前未发布实现候选
77

8+
- **SourceExistenceVerificationGate(规范吸纳防复发)**:`spec-absorption` 强制在输出「可吸纳」前对 source-root 做同名/近邻存在性验证;台账 `open/pending absorption` 不得单独成 absorb。`AbsorptionCandidateMatrixV1` 增加 `sourceExistence`;planner 阻断 `unverified` 与 `present/equivalent-covered` 的 absorb;并列「可关账清单」。负向:ledger-status-only。验证:`node scripts/plan-absorption-candidates.js --self-test`、`npm run test:residual-absorption-controls`、V81 needles。
9+
- **ExecutableAbsorption + ProbeNecessity(假吸纳禁止)**:吸纳必须可执行——`enforcementLevel` 不得为 checklist-only/none;每条 absorb 必填 `probeNecessity`(required/conditional/not-required/forbidden、alwaysOnImpact、复杂度与误报)。只写 Skill 正文或「以后再补探针」不得 ready/absorbed。分类器 `classifyExecutableAbsorptionSample`。验证:planner self-test + residual + V81。
10+
- **可执行吸纳主包 A 实施(R1~R4 + T2)**:`ProgressReportFastPath`(skill-gap/compliance/SC16);`TaskPhaseProjectionGate`(cp-gate/user-visible-output);`ClosedArtifactNoReviveGate`(cp-gate);`FormalRerunLightClassify` + `FindingObjectLayer`(consumer-validation)。探针:`scripts/lib/executable-absorption-gates.js` + `npm run test:executable-absorption-gates`(并入 host-parity / control-plane)。
811
- **GlobalOnly 全局 adapter 本地刷新 CLI(R1a)**:新增 `devcodex global-adapters apply [--dry-run] [--json]` 与 `npm run global-adapters:apply*`,从当前包根复用 `applyGlobalHostConfig` 刷新用户级五宿主 adapter(不 pack/不 publish)。nextStep/PC5/audit/V8/doctor/help/website intro 按源码 vs 已发布分流;次选 R1b `npm install -g .`、R2 pack+tarball、R3 `npm update -g`;bare `devcodex update` 仅 workspace。`package.json` files 已纳入 `global-adapters-cli` / guidance 模块,避免 pack 后 require 断裂。**Profile 01/02/04/05/06/07 已同步 R1a~R4**(PI-173)。任务:`bugs/GlobalOnly刷全局副本命令口径漂移修复`。
912
- **R1a 复审收口**:`host-parity-scorecard` repairSteps / recommendedEntry / userVisibleSummary 接入 `describeGlobalAdapterRefresh*`(源码 → `global-adapters apply`,已发布 → `npm -g`);`global-adapters apply` 在主机事务已提交但 Grok 集成失败时返回 `GLOBAL_ADAPTERS_HOSTS_COMMITTED_GROK_FAILED` + `partialState`;npm pack `files` 不再包含 `test-global-adapters-cli.js`。
1013
- **MCP runtime dep 防断**:将 `global-adapter-refresh-guidance.js` 纳入 `MCP_RUNTIME_DEPS` / `CLAUDE_MCP_RUNTIME_SCRIPT_DEPS`;`host-parity-scorecard` 对 guidance 模块 soft-require,避免精简 runtime 缺文件时 `MODULE_NOT_FOUND`。

‎scripts/lib/absorption-candidate-planner.js‎

Lines changed: 242 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ const CLASSIFICATIONS = new Set([
3232
'project-local',
3333
'already-covered'
3434
])
35+
const EXISTENCE_STATUSES = new Set(['absent', 'partial', 'present', 'equivalent-covered', 'unverified'])
36+
const LEDGER_DISPOSITIONS = new Set(['absorb-candidate', 'residual-candidate', 'close-ledger', 'defer'])
37+
const COVERED_EXISTENCE = new Set(['present', 'equivalent-covered'])
38+
const ENFORCEMENT_LEVELS = new Set(['hard-probe', 'structural-gate', 'conditional-probe', 'checklist-only', 'none'])
39+
const PROBE_CLASSES = new Set([
40+
'machine-sample',
41+
'structural-schema',
42+
'fixture-replay',
43+
'extend-existing',
44+
'checklist-only',
45+
'probe-forbidden'
46+
])
47+
const PROBE_NECESSITIES = new Set(['required', 'conditional', 'not-required', 'forbidden'])
48+
const ALWAYS_ON_IMPACTS = new Set(['none', 'test-only', 'conditional-path', 'always-on-path'])
49+
const WEAK_ENFORCEMENT = new Set(['checklist-only', 'none'])
3550

3651
function stableStringify(value) {
3752
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
@@ -70,6 +85,183 @@ function validateLayerChecks(layerChecks, pathPrefix) {
7085
return issues
7186
}
7287

88+
function validateSourceExistence(sourceExistence, pathPrefix, commonDecision) {
89+
const issues = []
90+
if (commonDecision === 'absorb') {
91+
if (!sourceExistence || typeof sourceExistence !== 'object' || Array.isArray(sourceExistence)) {
92+
return [issue(pathPrefix, 'source-existence-required', 'commonDecision=absorb requires sourceExistence')]
93+
}
94+
} else if (!sourceExistence) {
95+
return issues
96+
} else if (typeof sourceExistence !== 'object' || Array.isArray(sourceExistence)) {
97+
return [issue(pathPrefix, 'source-existence-invalid', 'sourceExistence must be an object')]
98+
}
99+
100+
const required = [
101+
'claimedCapability',
102+
'searchAnchors',
103+
'sourceRoot',
104+
'existenceStatus',
105+
'hitEvidence',
106+
'nearNeighborCoverage',
107+
'ledgerDisposition',
108+
'verifiedBy'
109+
]
110+
for (const field of required) {
111+
if (sourceExistence[field] === undefined || sourceExistence[field] === null || sourceExistence[field] === '') {
112+
issues.push(issue(`${pathPrefix}.${field}`, 'source-existence-field-missing', `${field} is required on sourceExistence`))
113+
}
114+
}
115+
if (!EXISTENCE_STATUSES.has(sourceExistence.existenceStatus)) {
116+
issues.push(issue(`${pathPrefix}.existenceStatus`, 'existence-status-invalid', 'existenceStatus is invalid'))
117+
}
118+
if (!LEDGER_DISPOSITIONS.has(sourceExistence.ledgerDisposition)) {
119+
issues.push(issue(`${pathPrefix}.ledgerDisposition`, 'ledger-disposition-invalid', 'ledgerDisposition is invalid'))
120+
}
121+
if (!Array.isArray(sourceExistence.searchAnchors) || sourceExistence.searchAnchors.length === 0) {
122+
issues.push(issue(`${pathPrefix}.searchAnchors`, 'search-anchors-required', 'searchAnchors must be non-empty'))
123+
}
124+
if (!Array.isArray(sourceExistence.hitEvidence)) {
125+
issues.push(issue(`${pathPrefix}.hitEvidence`, 'hit-evidence-array', 'hitEvidence must be an array'))
126+
} else if (
127+
COVERED_EXISTENCE.has(sourceExistence.existenceStatus) ||
128+
sourceExistence.existenceStatus === 'partial'
129+
) {
130+
if (sourceExistence.hitEvidence.length === 0) {
131+
issues.push(issue(`${pathPrefix}.hitEvidence`, 'hit-evidence-required', 'present/partial/equivalent-covered requires hitEvidence'))
132+
}
133+
}
134+
if (commonDecision === 'absorb') {
135+
if (sourceExistence.existenceStatus === 'unverified') {
136+
issues.push(issue(`${pathPrefix}.existenceStatus`, 'existence-unverified', 'cannot absorb while existenceStatus=unverified'))
137+
}
138+
if (COVERED_EXISTENCE.has(sourceExistence.existenceStatus)) {
139+
issues.push(issue(`${pathPrefix}.existenceStatus`, 'existence-already-covered', 'present/equivalent-covered cannot use commonDecision=absorb'))
140+
}
141+
if (sourceExistence.ledgerDisposition === 'close-ledger') {
142+
issues.push(issue(`${pathPrefix}.ledgerDisposition`, 'close-ledger-not-absorb', 'close-ledger disposition cannot absorb'))
143+
}
144+
}
145+
return issues
146+
}
147+
148+
/**
149+
* Classifier for SourceExistenceVerificationGate free-text / object samples.
150+
* @returns {'absorb-ok'|'residual-ok'|'close-ledger'|'ledger-status-only'|'existence-unverified'|'invalid-absorb-covered'}
151+
*/
152+
function classifySourceExistenceVerificationSample(sample) {
153+
const text = typeof sample === 'string' ? sample : ''
154+
const obj = sample && typeof sample === 'object' && !Array.isArray(sample) ? sample : null
155+
const status = obj?.existenceStatus ||
156+
(/existenceStatus\s*[:=]\s*([a-z-]+)/i.exec(text)?.[1]) ||
157+
(/existenceStatus[=:]\s*([a-z-]+)/i.exec(text)?.[1])
158+
const hasSearch = (Array.isArray(obj?.searchAnchors) && obj.searchAnchors.length > 0) ||
159+
/searchAnchors|sourceRoot|source-root|在 source|源码检索|rg |grep /i.test(text)
160+
const ledgerOnly = /状态[::]\s*(open|active|pending)|pending absorption|只根据台账|ledger-status-only/i.test(text) && !hasSearch
161+
if (ledgerOnly || (!hasSearch && /absorb|可吸纳|pure-open/i.test(text))) {
162+
return 'ledger-status-only'
163+
}
164+
if (!hasSearch || status === 'unverified' || (!status && /未验证|unverified/i.test(text))) {
165+
return 'existence-unverified'
166+
}
167+
if (status === 'present' || status === 'equivalent-covered' || /already-covered|already-fixed|可关账|close-ledger/i.test(text)) {
168+
if (/commonDecision\s*[:=]\s*absorb|decision\s*=\s*absorb|标 absorb|整包吸纳/i.test(text) &&
169+
!/不得|禁止|reject|close-ledger/i.test(text)) {
170+
return 'invalid-absorb-covered'
171+
}
172+
return 'close-ledger'
173+
}
174+
if (status === 'partial' || /residual-tail|residual-candidate|补洞/i.test(text)) {
175+
return 'residual-ok'
176+
}
177+
if (status === 'absent' || /existenceStatus\s*[:=]\s*absent|absent\b/i.test(text)) {
178+
return 'absorb-ok'
179+
}
180+
return 'existence-unverified'
181+
}
182+
183+
function validateProbeNecessity(probeNecessity, pathPrefix, commonDecision, enforcementLevel) {
184+
const issues = []
185+
if (commonDecision !== 'absorb') {
186+
return issues
187+
}
188+
if (!probeNecessity || typeof probeNecessity !== 'object' || Array.isArray(probeNecessity)) {
189+
return [issue(pathPrefix, 'probe-necessity-required', 'commonDecision=absorb requires probeNecessity')]
190+
}
191+
const required = [
192+
'probeClass',
193+
'necessity',
194+
'rationale',
195+
'probePlan',
196+
'existingProbeReuse',
197+
'alwaysOnImpact',
198+
'complexityDelta',
199+
'falsePositiveRisk'
200+
]
201+
for (const field of required) {
202+
if (probeNecessity[field] === undefined || probeNecessity[field] === null || probeNecessity[field] === '') {
203+
issues.push(issue(`${pathPrefix}.${field}`, 'probe-necessity-field-missing', `${field} is required on probeNecessity`))
204+
}
205+
}
206+
if (!PROBE_CLASSES.has(probeNecessity.probeClass)) {
207+
issues.push(issue(`${pathPrefix}.probeClass`, 'probe-class-invalid', 'probeClass is invalid'))
208+
}
209+
if (!PROBE_NECESSITIES.has(probeNecessity.necessity)) {
210+
issues.push(issue(`${pathPrefix}.necessity`, 'probe-necessity-invalid', 'necessity is invalid'))
211+
}
212+
if (!ALWAYS_ON_IMPACTS.has(probeNecessity.alwaysOnImpact)) {
213+
issues.push(issue(`${pathPrefix}.alwaysOnImpact`, 'always-on-impact-invalid', 'alwaysOnImpact is invalid'))
214+
}
215+
if (
216+
(probeNecessity.necessity === 'not-required' || probeNecessity.necessity === 'forbidden') &&
217+
!probeNecessity.skipProbeReason
218+
) {
219+
issues.push(issue(`${pathPrefix}.skipProbeReason`, 'skip-probe-reason-required', 'not-required/forbidden needs skipProbeReason'))
220+
}
221+
if (probeNecessity.necessity === 'required' && /以后|后续|待定|TODO|以后再说/i.test(String(probeNecessity.probePlan || ''))) {
222+
issues.push(issue(`${pathPrefix}.probePlan`, 'probe-plan-deferred', 'required probe cannot defer probePlan'))
223+
}
224+
if (probeNecessity.probeClass === 'checklist-only' && commonDecision === 'absorb') {
225+
issues.push(issue(`${pathPrefix}.probeClass`, 'checklist-only-not-active-absorb', 'checklist-only cannot absorb as enforceable active'))
226+
}
227+
if (probeNecessity.alwaysOnImpact === 'always-on-path' && !/UnaffectedIntent|base-changing|单独确认/i.test(String(probeNecessity.rationale || '') + String(probeNecessity.falsePositiveRisk || ''))) {
228+
issues.push(issue(`${pathPrefix}.alwaysOnImpact`, 'always-on-needs-base-note', 'always-on-path requires base-impact note in rationale or falsePositiveRisk'))
229+
}
230+
if (enforcementLevel && WEAK_ENFORCEMENT.has(enforcementLevel)) {
231+
issues.push(issue(`${pathPrefix.replace('probeNecessity', 'enforcementLevel')}`, 'weak-enforcement', 'absorb cannot use checklist-only/none enforcementLevel'))
232+
}
233+
if (enforcementLevel === 'hard-probe' && !['machine-sample', 'fixture-replay', 'extend-existing', 'structural-schema'].includes(probeNecessity.probeClass)) {
234+
issues.push(issue(`${pathPrefix}.probeClass`, 'hard-probe-class-mismatch', 'hard-probe requires machine/fixture/extend/structural class'))
235+
}
236+
return issues
237+
}
238+
239+
/**
240+
* @returns {'enforceable'|'text-only-fake'|'checklist-fake'|'probe-deferred'|'ok-structural'|'ok-probe'}
241+
*/
242+
function classifyExecutableAbsorptionSample(sample) {
243+
const text = typeof sample === 'string' ? sample : JSON.stringify(sample || {})
244+
if (/只改 (Skill|正文|Markdown)|text-only|无消费者|无探针.*absorbed|假吸纳/i.test(text) && !/禁止|invalid|blocked/i.test(text)) {
245+
return 'text-only-fake'
246+
}
247+
if (/checklist-only|仅清单|人工自觉/i.test(text) && /absorb|absorbed|active/i.test(text) && !/不得|禁止|否/i.test(text)) {
248+
return 'checklist-fake'
249+
}
250+
if (/necessity\s*[:=]\s*required/i.test(text) && /以后再说|TODO|待定|后续再补探针/i.test(text)) {
251+
return 'probe-deferred'
252+
}
253+
if (/enforcementLevel\s*[:=]\s*structural-gate|structural-schema/i.test(text)) {
254+
return 'ok-structural'
255+
}
256+
if (/enforcementLevel\s*[:=]\s*(hard-probe|conditional-probe)|probeClass\s*[:=]\s*(machine-sample|extend-existing|fixture-replay)/i.test(text)) {
257+
return 'ok-probe'
258+
}
259+
if (/ExecutableAbsorption|不按流程|谁会红/i.test(text) && /hard-probe|structural-gate|conditional-probe/i.test(text)) {
260+
return 'enforceable'
261+
}
262+
return 'text-only-fake'
263+
}
264+
73265
function validateCandidate(candidate, index) {
74266
const pathPrefix = `candidates[${index}]`
75267
const issues = []
@@ -87,12 +279,38 @@ function validateCandidate(candidate, index) {
87279
if (candidate.targetLayer && !CLASSIFICATIONS.has(candidate.targetLayer)) {
88280
issues.push(issue(`${pathPrefix}.targetLayer`, 'target-layer-invalid', 'targetLayer is invalid'))
89281
}
282+
if (candidate.enforcementLevel && !ENFORCEMENT_LEVELS.has(candidate.enforcementLevel)) {
283+
issues.push(issue(`${pathPrefix}.enforcementLevel`, 'enforcement-level-invalid', 'enforcementLevel is invalid'))
284+
}
90285
if (!Array.isArray(candidate.validationRoute) || candidate.validationRoute.length === 0) {
91286
issues.push(issue(`${pathPrefix}.validationRoute`, 'validation-route-required', 'validationRoute must be non-empty'))
92287
}
93288
issues.push(...validateLayerChecks(candidate.layerChecks, `${pathPrefix}.layerChecks`))
94-
if (candidate.commonDecision === 'absorb' && candidate.backlogClass !== 'pure-open' && !candidate.skipReason) {
95-
issues.push(issue(`${pathPrefix}.skipReason`, 'non-pure-open-needs-skip', 'non pure-open candidate cannot be absorbed without skipReason'))
289+
issues.push(...validateSourceExistence(candidate.sourceExistence, `${pathPrefix}.sourceExistence`, candidate.commonDecision))
290+
issues.push(...validateProbeNecessity(
291+
candidate.probeNecessity,
292+
`${pathPrefix}.probeNecessity`,
293+
candidate.commonDecision,
294+
candidate.enforcementLevel
295+
))
296+
if (candidate.commonDecision === 'absorb' && !candidate.enforcementLevel) {
297+
issues.push(issue(`${pathPrefix}.enforcementLevel`, 'enforcement-level-required', 'absorb requires enforcementLevel'))
298+
}
299+
if (candidate.commonDecision === 'absorb' && WEAK_ENFORCEMENT.has(candidate.enforcementLevel)) {
300+
issues.push(issue(`${pathPrefix}.enforcementLevel`, 'weak-enforcement', 'absorb cannot use checklist-only/none'))
301+
}
302+
if (candidate.commonDecision === 'absorb' && candidate.backlogClass !== 'pure-open' && candidate.backlogClass !== 'residual-tail' && !candidate.skipReason) {
303+
issues.push(issue(`${pathPrefix}.skipReason`, 'non-pure-open-needs-skip', 'non pure-open/residual-tail candidate cannot be absorbed without skipReason'))
304+
}
305+
if (candidate.commonDecision === 'absorb' && candidate.backlogClass === 'residual-tail') {
306+
if (candidate.sourceExistence?.existenceStatus !== 'partial') {
307+
issues.push(issue(`${pathPrefix}.sourceExistence.existenceStatus`, 'residual-needs-partial', 'residual-tail absorb requires existenceStatus=partial'))
308+
}
309+
}
310+
if (candidate.commonDecision === 'absorb' && candidate.backlogClass === 'pure-open') {
311+
if (candidate.sourceExistence?.existenceStatus && candidate.sourceExistence.existenceStatus !== 'absent') {
312+
issues.push(issue(`${pathPrefix}.sourceExistence.existenceStatus`, 'pure-open-needs-absent', 'pure-open absorb requires existenceStatus=absent'))
313+
}
96314
}
97315
return issues
98316
}
@@ -153,8 +371,24 @@ function buildLayeredAbsorptionDecision(candidate) {
153371
prevention: candidate.prevention || null
154372
}
155373
}
156-
if (candidate.backlogClass !== 'pure-open') blockers.push('backlog-class-not-pure-open')
374+
if (candidate.backlogClass !== 'pure-open' && candidate.backlogClass !== 'residual-tail') {
375+
blockers.push('backlog-class-not-absorbable')
376+
}
157377
if (!candidate.targetOwner) blockers.push('target-owner-missing')
378+
if (!candidate.sourceExistence) blockers.push('source-existence-missing')
379+
else {
380+
if (candidate.sourceExistence.existenceStatus === 'unverified') blockers.push('existence-unverified')
381+
if (COVERED_EXISTENCE.has(candidate.sourceExistence.existenceStatus)) blockers.push('existence-already-covered')
382+
if (candidate.backlogClass === 'pure-open' && candidate.sourceExistence.existenceStatus !== 'absent') {
383+
blockers.push('pure-open-requires-absent')
384+
}
385+
if (candidate.backlogClass === 'residual-tail' && candidate.sourceExistence.existenceStatus !== 'partial') {
386+
blockers.push('residual-requires-partial')
387+
}
388+
}
389+
if (!candidate.probeNecessity) blockers.push('probe-necessity-missing')
390+
if (!candidate.enforcementLevel) blockers.push('enforcement-level-missing')
391+
if (WEAK_ENFORCEMENT.has(candidate.enforcementLevel)) blockers.push('weak-enforcement')
158392
for (const key of LAYER_KEYS) {
159393
if (candidate.layerChecks?.[key]?.state === 'blocked') blockers.push(`layer-blocked:${key}`)
160394
}
@@ -204,8 +438,12 @@ function planAbsorptionCandidates(matrix) {
204438
module.exports = {
205439
LAYER_KEYS,
206440
buildLayeredAbsorptionDecision,
441+
classifyExecutableAbsorptionSample,
442+
classifySourceExistenceVerificationSample,
207443
planAbsorptionCandidates,
208444
sha256,
209445
stableStringify,
210-
validateAbsorptionCandidateMatrix
446+
validateAbsorptionCandidateMatrix,
447+
validateProbeNecessity,
448+
validateSourceExistence
211449
}

0 commit comments

Comments
 (0)