Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions bin/fde.js
Original file line number Diff line number Diff line change
Expand Up @@ -1773,21 +1773,33 @@ function looksLikePersonName(s) {
function signerFromLine(text) {
const t = String(text || '').replace(/^[-*+]\s+/, '').trim()
if (!t || /\?|\b(?:not|nobody|unclear|maybe|might|whether|could|should|if|unless|pending|unconfirmed)\b/i.test(t)) return ''
// A component/budget/release approver is not the engagement's acceptance
// signer. Infer only a bare authority statement or explicit outcome/test
// sign-off; leave other scope wording in the original note for agent review.
const roleWords = text => text.trim().split(/\s+/).every(word => word.match(ROLE_TOKEN)?.[0] === word)
const candidate = (match, who, rolePrefix = false) => {
if (!match || !looksLikePersonName(who)) return ''
const before = t.slice(0, match.index)
const after = t.slice(match.index + match[0].length).replace(/\[source:[^\]]*\]/gi, '').trim()
if (before.trim() && (!rolePrefix || !roleWords(before))) return ''
if (!/^(?:(?:on\s+)?(?:the\s+)?(?:acceptance tests?|(?:customer |delivered )?outcome))?[.!]?$/i.test(after)) return ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Adjacent scope qualifier creates global signer

For 'Jo signs off on the acceptance tests. Only for API compatibility.', candidate accepts the first sentence as unqualified authority. smartProposeText checks sentences independently, so applying the proposal records Jo as the engagement signer.

Learn more

Smart debrief examines each sentence for signers but preserves the full input line as context. The suffix check sees only the first sentence, so a restriction in the next sentence cannot stop a generic signer proposal. Applying the proposal writes that name through routeDebriefInput.

Example: Jo signs off on the acceptance tests. Only for API compatibility. [source: meeting:42] yields signer: Jo [source: meeting:42]; applying it records Jo as the engagement signer despite the qualification.

Recommended fix: Check adjacent sentences for scope restrictions in smartProposeText before proposing a signer, while retaining the original sourced note.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

return who.trim()
}
// "Priya (VP Eng) signs off" → Priya. "Finance controller (Helena) signs off" → Helena.
const titled = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
if (titled) {
const before = titled[1].trim()
const inside = titled[2].trim()
if (looksLikePersonName(before) && ROLE_TOKEN.test(inside)) return before
if (looksLikePersonName(inside)) return inside
if (looksLikePersonName(before)) return before
if (looksLikePersonName(before) && roleWords(inside)) return candidate(titled, before)
if (roleWords(before) && looksLikePersonName(inside)) return candidate(titled, inside)
return ''
}
const paren = t.match(new RegExp('\\(' + SIGNER_NAME + '\\)\\s+' + SIGNER_VERB + '\\b'))
if (paren && looksLikePersonName(paren[1])) return paren[1].trim()
if (paren && looksLikePersonName(paren[1])) return candidate(paren, paren[1], true)
const named = t.match(new RegExp('\\b' + SIGNER_NAME + '\\s+' + SIGNER_VERB + '\\b'))
if (!named) return ''
const who = named[1].trim()
return looksLikePersonName(who) ? who : ''
return candidate(named, who)
}

function setSigner(eng, who) {
Expand Down
40 changes: 40 additions & 0 deletions test/debrief-daily-updates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,46 @@ test('explicit named signer retains source after sign-off prose is removed', t =
assert.match(fs.readFileSync(path.join(f.eng, 'success.md'), 'utf8'), /\*\*Stakeholder who signs off:\*\* Mara Chen \[source: meeting:42\]/)
assert.match(fs.readFileSync(path.join(f.eng, 'stakeholders.md'), 'utf8'), /Mara Chen \[source: meeting:42\] signs off/)
})
for (const statement of [
'Jo approves API compatibility only.',
'Jo signs off on the budget.',
'Jo has final say on the release.',
'Finance controller (Jo) approves the budget.',
'Jo (VP Eng) signs off on API compatibility.',
'API compatibility is all Jo approves.',
'Budget approver Jo signs off.',
'Release manager Jo signs off.',
'Jo signs off on the acceptance tests for API compatibility only.',
]) {
test(`scoped approval stays sourced context: ${statement}`, t => {
const f = fixture(t)
const files = ['success.md', 'stakeholders.md']
const before = files.map(file => fs.readFileSync(path.join(f.eng, file), 'utf8'))
const note = `${statement} [source: meeting:scoped-42]`
const proposed = f.run(['debrief', '--smart'], note + '\n')
assert.equal(proposed.status, 0, proposed.stderr)
const proposal = fs.readFileSync(path.join(f.eng, '.debrief-propose'), 'utf8')
assert.doesNotMatch(proposal, /^signer:/m)
assert.ok(proposal.includes(note))
assert.equal(f.run(['debrief', '--review']).status, 0)
assert.deepEqual(files.map(file => fs.readFileSync(path.join(f.eng, file), 'utf8')), before)
assert.equal(f.run(['debrief', '--apply']).status, 0)
assert.deepEqual(files.map(file => fs.readFileSync(path.join(f.eng, file), 'utf8')), before)
assert.ok(fs.readFileSync(path.join(f.eng, 'context.md'), 'utf8').includes(note))
})
}

test('plural acceptance tests retain the sourced signer', t => {
const f = fixture(t)
const note = 'Jo signs off on the acceptance tests. [source: meeting:acceptance-42]'
const proposed = f.run(['debrief', '--smart'], note + '\n')
assert.equal(proposed.status, 0, proposed.stderr)
assert.match(fs.readFileSync(path.join(f.eng, '.debrief-propose'), 'utf8'), /^signer: Jo \[source: meeting:acceptance-42\]$/m)
assert.equal(f.run(['debrief', '--apply']).status, 0)
assert.match(fs.readFileSync(path.join(f.eng, 'success.md'), 'utf8'), /\*\*Stakeholder who signs off:\*\* Jo \[source: meeting:acceptance-42\]/)
assert.ok(fs.readFileSync(path.join(f.eng, 'context.md'), 'utf8').includes(note))
})

test('invalid delivery columns fail before changing records', t => {
const f = fixture(t); const before = fs.readFileSync(path.join(f.eng, 'delivery.md'), 'utf8')
assert.notEqual(f.run(['log', 'delivery', 'slice|promise|measured']).status, 0)
Expand Down
Loading