Repository navigation
feat(intents): choose AI-DLC settings per intent, and accept the composer's proposal - #582
Conversation
JWThewes
left a comment
There was a problem hiding this comment.
@eipasteur Two P2 findings remain in per-intent settings: a retiring draft save can omit settings after navigation, and Apply can retain a scope default as an override of a later scope. Each is attached separately with a reproduction and finite regression criteria.
| useEffect(() => { | ||
| if (!doc) return; | ||
| seededRef.current = false; | ||
| hadSettingsRef.current = false; |
There was a problem hiding this comment.
[P2] Keep settings-presence state owned by its draft document
@eipasteur hadSettingsRef is shared across document lifetimes, but a retiring document's queued save can still be running when this effect resets it for the next document. That old save retains its original doc; after its awaited revision read finishes, getSaveData at line 395 consults the new document's flag and omits aidlcSettings from the old document's PATCH.
Reproduced using the real hook/save queue and two real Yjs documents: set guardPolicy=strict on intent A, delay A's draftEditState response, navigate to intent B with no settings, then release A's response. A's final business-record save succeeds with title, prompt, scope, grid/skips and revision, but no aidlcSettings, even though A's original Yjs document still contains {guardPolicy: "strict"}. Clearing the last override also needs the sticky presence state so the retiring save sends an explicit null.
Please scope this presence state to the document/save queue that owns it so navigation cannot change how an in-flight save serializes its document. Add delayed-revision navigation regressions for both adding an override and clearing the last override; assert A's final PATCH contains the settings or explicit null, respectively, and B's state remains independent.
| const proposed = catalogSettingsAware ? aidlcSettingsFromComposeProposal(proposal) : {}; | ||
| const aidlcSettings = Object.keys(proposed).length > 0 ? proposed : null; |
There was a problem hiding this comment.
[P2] Normalize proposed settings before applying an unchanged scope
@eipasteur Apply stores the raw proposed values here. If the proposal selects the already-selected scope, the preview effect does not rerun, so its settings normalization never removes values equal to that scope's defaults. The server normalizes the saved record, but the shared Yjs document retains those values as explicit overrides.
Reproduced with the real page, hook/save queue, two synchronized Yjs documents, committed scope definitions and the production server normalizer: start on feature (default guardPolicy=off), Apply a matched feature proposal with guardPolicy=off, then switch to enterprise (default guardPolicy=strict). Both documents retain {guardPolicy: "off"} after Apply; switching scopes persists it as an override of enterprise's strict default and the UI shows “Changed for this intent (scope default: Strict)”. Merely accepting the current scope's default can therefore disable the guard in the later scope.
Please normalize proposed values against the proposal's validated settings scope before the atomic Apply transaction, including when scope/grid selection is unchanged. Add a regression for same-scope/default Apply followed by a scope with a different default: both peers should have no redundant override after Apply, and the later scope should use its own default in the UI and persisted record.
The pinned release's chosen scope decides Guard Policy, Plan Approval,
the collaborators and the project type for every intent that runs on it.
A team that wants the `classic` stage set with a strict guard, or one
feature built without the plan gate, has to pick a different scope and
accept everything else it brings.
The plan resolver now takes an `intentSettings` overlay: a plain
`{ settingKey: scalar }` map of the OVERRIDES recorded for one intent.
Precedence is intent, then scope, then the release default, so a
recorded choice wins over the scope and a scope change alone cannot
lower a setting the intent recorded.
Only deltas from the scope default are ever stored, which is what lets
the stage that loses its plan gate carry two mutually exclusive and
exhaustive cases — `intent` or `scope-default` — with no actor frozen
into the plan. The project type reaches the prompt as a decision in
both directions: an explicit Greenfield choice now renders, and the
classification stage records a decided type instead of scanning against
it.
The plan also publishes `aidlcSettingsAuthored`, the settings the chosen
scope actually authors, which is the only set anything may offer. It is
absent for a release that authors none, so an unpinned, 2.3.3, 2.8.2 or
2.9.0 plan is unchanged and nothing yet persists a setting.
A setting is a team decision about one intent, so it is draft selection state: it travels in the same `PATCH` body as the scope and the composed grid, under the same draft-revision CAS, and nothing else can write it. There is no second route and no second write path to order against the first. `normalizeAidlcSettings` is the one rule, in this order: drop a key the effective selection does not author and name it, refuse a value outside the key's vocabulary or authored type, and omit a value equal to the authored default. Only deltas are stored, so no row can later assert that a value came from a scope that does not supply it. Settings are only ever normalised against a selection that arrived in the same request — `400 selection_required_with_settings` otherwise — and the row the authored map and the audit diff come from must be the row the CAS replaces, so an eventually consistent read is refused with `409 edit_conflict` rather than producing a correct write with a timeline entry describing a change nobody made. One `v2.intent.settings.changed` event per changed key, in plain words, with the authenticated caller of that `PATCH` as the actor. A save that changes no setting writes no event. `409 settings_locked` keeps its own code once the run has started, and the DTO omits the field entirely for an intent that has none, so every unpinned, 2.3.3, 2.8.2 and 2.9.0 intent is byte-identical to what a deployed client already parses.
…olves The settings were consumed by the intents API's plan check and by nothing else. The orchestrator walk called `loadPlan` with its own hand-written projection, the stage dispatch forwarded the scope, the skips and the grid but not the settings, and the container rebuilt its plan twice in `resolveStage` without them. A stored override therefore changed no running workflow: a raised Guard Policy and a withdrawn Plan Approval were both silently ignored. There is now ONE per-intent plan projection contract in `shared/intent-methodology.js`, and the walk, the dispatch and the container all build their projection from it — so a projection field cannot be added for the API and silently missed by the run again. META stores the settings under their own name and the resolver calls them `intentSettings`; the one alias lives with the contract. The regression is connected rather than payload-shaped: the dispatch payload the orchestrator really produced is handed to the real container entry point and the policy the stage materializes under is asserted, with a no-settings control on the same fixture. An assertion on the payload alone passes for a container that ignores the field, which is exactly the shape the original miss had. An intent that recorded nothing sends exactly the payload it sent before, with no new key.
A human approving a stage that no plan gate preceded should be told so, and told why. The gate now carries an advisory notice on the one stage that would have asked for a plan approval and did not. Two cases, and they are exhaustive because a value equal to the scope's own default is never stored: either the pinned release's named scope turns Plan Approval off by default and no project member changed it, or it was turned off for this intent before the run started and the scope's own default leaves it on. Neither sentence can be made false by a later scope change. The notice names nobody. A name frozen into a pinned plan is what let a historical default be attributed to a scope that never supplied it; the intent's append-only timeline is where who-changed-what lives, and the notice points at it. The finding is advisory, so no gate outcome changes, and it is absent on every release that never had the switch.
…step On the compose page, under the scope picker, a pinned 2.11.0 intent now shows an AI-DLC settings block: one row per setting the chosen scope actually authors, pre-filled with that scope's own value, each with a plain sentence saying what the option does to the run, and a line saying whether the run follows the scope default or a change made for this intent. The settings are a new top-level `Y.Map`, one entry per setting key. Merged per key, so two members changing different settings concurrently both survive; the same setting is last-writer-wins, exactly like two members changing the scope, and both pages converge on one answer. `setAidlcSetting` is local and synchronous like `setScope` — no pending state, no per-setting promise, no second in-flight operation — so the existing `flushDraft` barrier before Start covers a choice made one tick earlier with no new code. They are seeded only inside the empty-document seed, like the scope, so a hydration is not a local edit and a classic no-settings visit still makes no request at all. `normalizeAidlcSettings` mirrors the server rule of the same name: picking the value the scope already has clears the entry instead of storing a value equal to the default, and a selection that stops authoring a setting withdraws it — in one transaction, writing nothing when there is nothing to withdraw — with the page naming what went.
The compatibility document's scope-policy section said what each scope switch does and nothing about overriding one for a single intent. Add the contract a maintainer has to be able to read without the diff: one write path, overrides only, validated against the selection in the same body, DRAFT-only with its own refusal code, audited per changed key, projected through one contract into every plan a run resolves, and absent for every release that authors no such setting.
From 2.11 the composer returns the ceremony settings beside the grid: one Guard Policy, the six `scope_settings` it echoes from its validator, and the stock base scope a custom plan runs on. Nothing parsed them, so a valid proposal reached the platform with its settings silently gone. The parser now keeps each member it was sent, reading every legal value from the capability registry rather than from a second list, and refuses an in-flight `settingsChanges` BY NAME — this runtime cannot change a setting on work that has already started, and parsing it as a no-op would leave the composer believing its change had been taken. The richer output contract and the settings grounding are GATED on the pinned catalog actually authoring per-intent settings, never on a version string. A release that authors none — no pin, 2.3.3, 2.8.2, 2.9.0 — is asked for none and grounded in none, so its output contract is byte-identical and its whole compose prompt is the one it was. The parser stays ungated, so a model that volunteers a member on an older pin is still parsed rather than refused. The grounding carries the EFFECTIVE settings — the selected scope's own value for each setting with any override for this intent on top — and the dialect annex answers `engine config get` from that section, refuses `engine config set` and `scope save` by name, and forbids hunting for the values in a settings or environment file. The rows name nobody.
…ettings scope A proposal must not offer a setting the selection does not author: Apply would prune it silently under a card that says Applied, and the draft write would refuse the value the member thought they accepted. The authored check ran only when a custom proposal happened to carry the optional `baseScope`. A matched proposal returned before reaching it, and a custom proposal that omitted the member was never checked at all, so both could finish COMPLETED with settings nothing could carry. The settings scope is now resolved for all three shapes a proposal takes — the matched stock scope, an explicit base scope, or the scope the selection already runs on — and the same authored-key check runs before COMPLETED in every one of them. A proposal that resolves no scope at all and still offers a setting is refused by name rather than completing. The settings a proposal carries that have no per-intent surface here are excluded from the check: they are reported to the member and keep following the scope. The resolved settings scope is recorded on the compose row, so Apply normalises the accepted values against the same authored map the validation used instead of re-deriving one that may since have changed.
A composed grid's `scope` is a display label. `bugfix-custom`, or a composer's own proposal name, names no SCOPE block — so the moment a run was customized the whole per-scope policy, and every setting a member could choose, silently vanished. The plan now reads its policy from the stock scope the caller recorded, falling back to `scope` itself, so a selection that names its scope directly resolves byte-for-byte what it resolved before. The field is persisted on the intent, verified against the pinned workflow's own scope vocabulary, offered on the preview so the member sees the same context the save will resolve, and exposed through the one shared plan projection rather than another argument list. It belongs to the grid, so clearing the grid clears it in the SAME write. A row that kept a base scope applying to no grid would refuse every later save and Start with nothing a client could do about it, because the field only ever travels with a grid. An explicitly sent base scope without a grid is a 400. Omitted from the row and the DTO for every selection that names its scope directly, which is every intent created before the field existed.
… dispatch The base scope reached the initial walk and stopped there. The dispatch built its own argument list, and the container resolved its plan twice in `resolveStage` without it, so a customized run gated on the base scope's policy and then materialized no policy at all — a Strict guard and an On collaborator roster announced at the gate and absent from the prompt. The dispatch now builds its WHOLE projection from the shared contract in `shared/intent-methodology.js`, the same one the walk and the intents API use, instead of a parallel list. That parallel list is how the settings reached the walk and missed the stage, and then how the base scope did; there is now one place to add a projection field. The container takes the base scope on both of its plan resolutions, so the probe and the resolved plan agree with each other and with the walk. The regression is connected: the dispatch payload the orchestrator really produced is handed to the real container entry point and the materialized policy is asserted, with a no-base control on the same fixture that resolves no scope policy. A selection that names its scope directly sends no new key.
…e and grid The compose card now shows what the composer proposed before a member accepts it: every proposed setting, in the methodology's own words, with the ones this intent can actually take marked as such and the rest shown as what the plan will follow — so nothing the composer sent is silently absent from what the member reads. A custom proposal also names the stock base scope and depth it runs on. Apply is ONE local transaction: the scope, the grid, the base scope its policy comes from and the accepted settings all land together, and the whole selection then reaches the server in one body under one CAS. The card is Applied because that transaction committed, so there is no asynchronous result to inspect, no partial acceptance, and no ordering between persisting the selection and recording the settings. A scope change made right after Apply simply wins, because both are local writes to the same document. The settings scope the compose job validated the proposal against rides along, so the accepted values are written for the scope that really authors them; a proposal that omits its base scope keeps the one the selection already runs on, never the display label. An override the accepted plan does not carry goes back to the scope default rather than surviving from the selection the member just replaced. Customizing the stage grid by hand keeps the scope its policy comes from, which used to be withdrawn on the first stage toggle.
Whether a draft has ever had a per-intent setting decides whether its save sends aidlcSettings at all. The flag was one per page, reset when the page moved to another draft, while a retiring draft's queued save reads it only after its revision read returns. That save then answered for the new draft and dropped the retiring draft's settings, or the explicit null that clears its last override, from its own final PATCH. The flag is now kept per shared document.
…ction does not change Applying a proposal for the scope already selected changes no selection, so the preview that prunes settings against the selection's authored map never re-runs. A proposed value equal to that scope's default then stayed in the shared document as a choice, and a later scope change turned it into a real override of the new scope's own default, for example keeping Guard Policy off on a scope whose default is strict. Apply now normalizes the proposed values against the current selection's authored map when the selection is unchanged; a changed selection is still pruned by its own preview.
…posal was validated against Apply normalized a proposal's settings against the defaults of the selection the page was previewing, and only when the proposal kept that selection. Those are not always the defaults the proposal was validated against: a custom grid can name another base scope, and a preview can still describe the previous scope while it reloads. A real override could then be dropped, so a run executed the scope default the member had just turned down, or a default value was kept as a choice. The compose job now records the authored defaults of the settings scope beside that scope, the compose API returns them, and Apply normalizes the accepted values against exactly those, whatever the page previews. A proposal recorded before the defaults were keeps the earlier behavior, which now also requires the base scope to match.
d0ba45f to
0100aee
Compare
eipasteur
left a comment
There was a problem hiding this comment.
Both findings are addressed, each with a test:
- [P2] Settings presence is now tracked per shared document, so a retiring draft's queued save still sends its own settings, or the explicit
nullthat clears its last override, after navigation. Delayed-revision tests cover adding and clearing an override and check the next draft keeps its own request shape: f17e77d - [P2] Apply now normalizes proposed values against the defaults of the settings scope the proposal was validated against, which the compose session now records. A value equal to that default is not stored, and a real override is kept whatever the page previews or whether its preview has landed, so Guard Policy
offaccepted on a strict-default base scope reaches the selection. A proposal recorded before this change keeps the previous behavior: d6c03ff, 0100aee
The branch is rebased on main now that #581 is merged.
|
TEST PURPOSES ONLY — DO NOT TAKE INTO ACCOUNT. Automatically computed PR review signals (deterministic)Cognitive complexity and branch coverage help focus review on functions and their tests; these signals are advisory. Complexity and coverage — 15 functions
Show 5 more functions
🟢 Improving · 🟠 Needs attention · 🔴 High risk · 🟣 Highest risk — = unavailable · n/a = not applicable · * = incomplete tests Isobar mapFixed repository map: red marks changed files; green marks static name matches or import links across components. These links indicate possible reach, not proven runtime impact or a risk grade. Signals
Legend
|

In short
#581 applies the settings each 2.11 scope defines. A team that wants, say, the
classicstages with a strict Guard Policy, or one feature without the plan approval step, still has to pick another scope and take everything else that comes with it.This PR lets the team choose. On the compose page, an intent pinned to a release with these settings shows one row per setting the chosen scope defines, filled with the scope's value, with a sentence on what each option does and whether the intent follows the scope or changed it. Members working on the same draft see each other's changes live. The choice is locked when the run starts and the agent can never change it. The composer agent can also propose settings with its plan; nothing applies until a member presses Apply.
Stacked on #581 (
feat/aidlc-211-scope-settings).What changes
409 settings_locked.Behaviour by release
Validation
Limitations and follow-ups
sensors,learnings,summary_confirmation) are shown but stay set by the scope.