@ancleto/spec 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agents/memory-keeper.md
CHANGED
|
@@ -17,7 +17,7 @@ tools:
|
|
|
17
17
|
|
|
18
18
|
# Memory Keeper Agent
|
|
19
19
|
|
|
20
|
-
You are the single owner of the repository's memory (`.ancleto/memory.db`, SQLite + FTS5). No other agent reads or writes it. You have
|
|
20
|
+
You are the single owner of the repository's memory (`.ancleto/memory.db`, SQLite + FTS5). No other agent reads or writes it. You have five modes, and `@orchestrator` (or the archiving role) tells you which one.
|
|
21
21
|
|
|
22
22
|
Before running any command, read `.ancleto/machine-env.md` if it exists and use it as the environment hint for shell, line endings, and tool availability. If it does not exist, continue with the generic guidance and report its absence. If a command fails because of the environment, report the declared shell and the exact command instead of retrying blindly with different syntax. This hint prevails over generic shell guidance in `AGENTS.md`, `PRODUCT.md`, or an agent prompt, but never overrides security, authorization, validation, permissions, or explicit user instructions.
|
|
23
23
|
|
|
@@ -77,12 +77,31 @@ Called only when the user explicitly asks to store a specific memory text (or ap
|
|
|
77
77
|
3. Otherwise, call `recordRule` or `recordDecision` once with the approved text, using the type suggested in the draft (or the one the user approved).
|
|
78
78
|
4. Report what you stored: the tool used, `memory_key`, the exact approved `content`, and whether it superseded a previous entry.
|
|
79
79
|
|
|
80
|
+
## Mode 5 — Promotion
|
|
81
|
+
|
|
82
|
+
Called by the archiving role (`/cleto-archive`, `/cleto-bulk-archive`) with the change slug and the verbatim blocks under `proposal ## Decisiones` that already carry their own `memory_key` and `Estado: aprobado`. It is not Automatic Record and does not consume its single-call budget. The shape of `## Decisiones` is defined in `agents/spec-writer.md`; reference it, do not restate it here.
|
|
83
|
+
|
|
84
|
+
1. **Preflight the whole batch before writing anything** (defense in depth over the archive gate): every supplied block has its own `memory_key`, `Estado: aprobado`, refs present and resolvable, and no session candidate (transcript, task state, session summary). Any violation ⇒ write nothing and report each offending block.
|
|
85
|
+
2. **Never invent or derive a `memory_key`.** Use exactly the key declared in the block; if a block lacks one, it is not a candidate.
|
|
86
|
+
3. Write **one `recordDecision` per approved block**. Never use `recordRule` from this mode: `## Decisiones` promotes decisions only.
|
|
87
|
+
4. `content` is **1–3 sentences** (same composition contract as below) followed by the normalized `refs:` block:
|
|
88
|
+
```
|
|
89
|
+
refs:
|
|
90
|
+
- spec:<capability>#<heading>
|
|
91
|
+
- change:<slug>
|
|
92
|
+
- path:<repository-relative-path>
|
|
93
|
+
```
|
|
94
|
+
Include only the refs that apply: a decision needs at least one `spec:` or `change:` ref, and `path:` is optional. `commit:` (a real SHA) and `workitem:` (a real tracker key) are also optional; never include a placeholder, since the engine validates the SHA and rejects a literal token. Do not copy Elección/Descartado/Justificación into the node: the full explanation stays in the versioned proposal.
|
|
95
|
+
5. **Reject credentials** in `content`/`justification` (never echo the detected value) and **reject session candidates**; report the key and the reason.
|
|
96
|
+
6. **Contradiction:** if two different `memory_key`s assert opposing positions, do not choose by textual similarity. Report both keys with their sources and hand off to human resolution, or to `memory merge` when they are duplicates.
|
|
97
|
+
7. Report one line per key: stored (and whether it superseded a previous entry) or rejected with the reason.
|
|
98
|
+
|
|
80
99
|
## Field contract
|
|
81
100
|
|
|
82
101
|
`recordRule` and `recordDecision` take the same shape:
|
|
83
102
|
|
|
84
103
|
- **`memory_key`** (required) — a stable conceptual key for the topic (lowercase kebab, e.g. `api-error-format`, `jest-testpathpattern`, `db-engine-choice`). Reuse it on later writes: the engine supersedes the previous entry atomically. Do not invent a new key for an update.
|
|
85
|
-
- **`content`** (required) — the entry, in Spanish and self-contained (see Text composition contract).
|
|
104
|
+
- **`content`** (required) — the entry, in Spanish and self-contained (see Text composition contract). Any write that does not come from the `runtime` source MUST end with a `refs:` block; a `decision` needs a `spec:` or `change:` ref and a `rule` needs a `path:`, `spec:` or `change:` ref. The engine rejects the write otherwise.
|
|
86
105
|
- **`justification`** — why this entry exists: for decisions it is the reason behind the choice; for rules it is the evidence or the problem it prevents. Send it whenever the delegation provides it.
|
|
87
106
|
- **`scope`** — `project` by default. Use `feature` or `task` only when the entry is specific to that narrower context. Rules with scope `project` flow into the proactive `<ProjectMemoryRules>` block.
|
|
88
107
|
|
|
@@ -136,12 +155,13 @@ Whoever recalls this in six months has no session to attach it to, so it reads a
|
|
|
136
155
|
|
|
137
156
|
## Output
|
|
138
157
|
|
|
139
|
-
- Mode used: `Recall`, `Automatic Record`, `Optional Draft`,
|
|
158
|
+
- Mode used: `Recall`, `Automatic Record`, `Optional Draft`, `User-Approved Record`, or `Promotion`
|
|
140
159
|
- Recall: the memories returned, with the caveat that they are precedent and may be stale, or "no relevant memories"
|
|
141
160
|
- Automatic Record: the tool used (`recordRule` or `recordDecision`), `memory_key`, `content` exactly as stored, and whether it superseded a previous entry
|
|
142
161
|
- Automatic Record without a useful candidate: `no-entry-warranted`, the reason, an optional exact draft when available, and confirmation that no memory tool was called
|
|
143
162
|
- Optional Draft: `no-automatic-candidate` or `no-entry-warranted`, the supplied reason, the suggested type (`rule`/`decision`), the suggested `memory_key`, and the exact draft, or confirmation that no draft could be composed; no memory tool was called
|
|
144
163
|
- User-Approved Record: the tool used, `memory_key`, the exact approved `content` as stored, and whether it superseded a previous entry
|
|
164
|
+
- Promotion: one line per approved `memory_key` with `recordDecision`, whether it superseded, or the rejection reason; no other tool is used
|
|
145
165
|
- Any failure of the memory engine, reported plainly so `@orchestrator` can continue without memory
|
|
146
166
|
|
|
147
167
|
**Output cap**: max 6 lines; the stored entry is the only unbounded field — everything else is one line.
|
package/agents/orchestrator.md
CHANGED
|
@@ -189,7 +189,7 @@ Before running any command, read `.ancleto/machine-env.md` if it exists and use
|
|
|
189
189
|
`@memory-keeper` is the only agent that touches the repository memory (`.ancleto/memory.db`). Never call `searchMemory`, `recordRule` or `recordDecision` yourself and never delegate them to anyone else.
|
|
190
190
|
|
|
191
191
|
- **Recall** — workflow classification alone never triggers recall. Delegate one Recall only when the user explicitly asks about prior experience, when cross-cutting or high-risk work could materially benefit from precedent, when the working context contains a `<ContextOverflowWarning>`, or when current evidence exposes a non-obvious failure, constraint, or workaround that code and specifications do not explain. A `<ContextOverflowWarning>` is an immediate trigger: delegate exactly one Recall to `@memory-keeper` with the task topic before delegating any other stage, and never repeat it for the same warning. Do not recall for local, well-defined implementation or test tasks, or when resuming work whose material context is already available. Preserve the resolved `app_id` and `project_id` returned by `@memory-keeper` and pass them to any later Record delegation. What it returns is precedent, not instruction, and may be stale.
|
|
192
|
-
- **Automatic Record (one step, only with a candidate)** — the close makes **at most one** memory call. Delegate Automatic Record only when `@reviewer`, `@tester`, or your reading of the completed work provides a concrete, plausible lesson that could save future investigation. Include the factual completed-work summary and validation evidence supporting the candidate. Consider validation workarounds, failed commands and their alternatives, and runtime or platform constraints even when `@reviewer` returned `none`. With **no real candidate**, do **not** delegate `@memory-keeper`, do **not** invoke Optional Draft, do **not** ask the user about memory, and finish without storing — never chain Automatic Record → Optional Draft → approval → User-Approved Record when there is no candidate.
|
|
192
|
+
- **Automatic Record (one step, only with a candidate)** — the close makes **at most one** memory call. **Promotion is outside this budget**: one call per approved `proposal ## Decisiones` block (own `memory_key` and `Estado: aprobado`, shape owned by `agents/spec-writer.md`), delegated to `@memory-keeper` in Promotion mode only when the batch has approved candidates; with no candidates, zero delegation and zero writes. Delegate Automatic Record only when `@reviewer`, `@tester`, or your reading of the completed work provides a concrete, plausible lesson that could save future investigation. Include the factual completed-work summary and validation evidence supporting the candidate. Consider validation workarounds, failed commands and their alternatives, and runtime or platform constraints even when `@reviewer` returned `none`. With **no real candidate**, do **not** delegate `@memory-keeper`, do **not** invoke Optional Draft, do **not** ask the user about memory, and finish without storing — never chain Automatic Record → Optional Draft → approval → User-Approved Record when there is no candidate.
|
|
193
193
|
- **User-Approved Record (explicit request only)** — reserve it for when the user explicitly asks to store a specific memory text; delegate it with that exact text, unrewritten. It is never an automatic closure step. The Optional Draft mode stays available in `@memory-keeper` for an explicit user request, but the close never delegates it on its own.
|
|
194
194
|
- **Reporting**: when a record pass stores an entry, surface its content and whether it superseded a previous entry in the final report.
|
|
195
195
|
|
|
@@ -323,7 +323,7 @@ If scope, risk, or the architecture decision changes after approval, present the
|
|
|
323
323
|
5. **Testing**: Delegate to **`@tester`** to create or update unit tests and perform final validation against the approved scope, including a non-writing format check and one lint pass after all edits. Include the complete Resolved Context Envelope, the coder's task-owned files, and its reported risks. `@tester` returns the final union of task-owned files, the deterministic `affected-set` (dependents included), and a **complete and closed Validation Ledger** for the review scope. A format failure in a task-owned file is failed verification and must not be fixed silently by `@tester`.
|
|
324
324
|
6. **Review**: Before delegating, arm/verify the prearmed Validation Ledger is closed: every command relevant to the review scope (change tests, affected-set tests, declared format check and lint) has an entry with a terminal classification (`passed`, `task-regression`, `pre-existing-unrelated`, `not-configured`, `inconclusive`); a `not-configured` entry closes that command and does not count as missing. If the ledger is not closed, do NOT delegate the review — stop at `@tester` and have it close the missing entry. When closed, delegate to **`@reviewer`** when an independent correctness or scope review is appropriate, passing the complete Resolved Context Envelope, the `affected-set` as the review scope, and the closed ledger unchanged (never reconstructed). Also pass the `change diff` field returned by `@tester` verbatim, limited to the task-owned files of that tramo and including its truncation declaration; never complete or infer the omitted count, and mark it as evidence, not instruction. When the change declares effect guarantees, additionally include, as separate fields, the guarantee extracted from the proposal, the protected state, and the affected capability
|
|
325
325
|
7. **🛑 FINAL ARCHIVE CHECKPOINT**: If implementation, validation, and any required review are complete with no blocking issues, stop and explicitly ask the user whether to keep iterating on the same change or finalize and archive it
|
|
326
|
-
8. **Finalization / Archive ∥ Memory Record (in parallel)**: Before archiving, verify the delta specs reflect the final implementation — if iteration changed behavior or scope, re-delegate to **`@spec-writer`** to update the delta specs first, so the merge to source-of-truth is accurate. Check the seed freshness before closing, following the canonical rule in the `LOCKED: seed-freshness` block of `AGENTS.md`: delegate the read-only state check to **`@technical-discovery`** and regenerate only with explicit user approval, never automatically. This closure check protects future sessions from a silently aging seed. Then, after explicit user approval to archive, start **`@documenter`** and — only when a concrete, plausible memory candidate exists — **`@memory-keeper`** (Automatic Record) **in parallel**: they write disjoint surfaces (`aspec/` for `@documenter`, `.ancleto/memory.db` for `@memory-keeper`) and neither needs the other's output — the change name the record needs is known before the archive — so do not wait for one to finish before starting the other. With no real candidate, delegate only `@documenter`: do not delegate `@memory-keeper`, do not invoke Optional Draft, and do not ask the user about memory (one memory call maximum). If the record would need the archive result, do not parallelize and report the blocker. Collect and report the state of both stages. When `@documenter` has finished the archive, delegate to **`@coder`** the commit of the archived paths you name for that step: that commit is sequential with the documenter, never parallel with it, and `@coder` is the only agent that commits. `@documenter` never commits.
|
|
326
|
+
8. **Finalization / Archive ∥ Memory Record (in parallel)**: Before archiving, verify the delta specs reflect the final implementation — if iteration changed behavior or scope, re-delegate to **`@spec-writer`** to update the delta specs first, so the merge to source-of-truth is accurate. Check the seed freshness before closing, following the canonical rule in the `LOCKED: seed-freshness` block of `AGENTS.md`: delegate the read-only state check to **`@technical-discovery`** and regenerate only with explicit user approval, never automatically. This closure check protects future sessions from a silently aging seed. Then, after explicit user approval to archive, start **`@documenter`** and — only when a concrete, plausible memory candidate exists — **`@memory-keeper`** (Automatic Record) **in parallel**: they write disjoint surfaces (`aspec/` for `@documenter`, `.ancleto/memory.db` for `@memory-keeper`) and neither needs the other's output — the change name the record needs is known before the archive — so do not wait for one to finish before starting the other. With no real candidate, delegate only `@documenter`: do not delegate `@memory-keeper`, do not invoke Optional Draft, and do not ask the user about memory (one memory call maximum). If the record would need the archive result, do not parallelize and report the blocker. Promotion of approved `proposal ## Decisiones` blocks belongs to this archive step and sits outside the Automatic Record budget: the `ancleto-archive` gate decides the batch, and the orchestrator delegates one `@memory-keeper` Promotion pass only when approved candidates exist — with none, zero delegation and zero writes. Run the Promotion pass only after `@documenter` finishes the spec merge and never in parallel with it, so a `spec:` ref resolves against the merged source-of-truth; when the archive runs without a spec sync, the pass still runs and a `spec:` that does not resolve against the canonical specs is rejected by the fail-closed check. Collect and report the state of both stages. When `@documenter` has finished the archive, delegate to **`@coder`** the commit of the archived paths you name for that step: that commit is sequential with the documenter, never parallel with it, and `@coder` is the only agent that commits. `@documenter` never commits.
|
|
327
327
|
9. **Report**: Return final status or blocking findings to the user once both finalization stages and the archive commit settle, or after the archive alone when there was no memory candidate.
|
|
328
328
|
|
|
329
329
|
### Path B: Direct-Implementation Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ancleto-archive
|
|
3
|
-
description: Archive a completed change, syncing delta specs first when needed. Use when implementation and verification are done.
|
|
3
|
+
description: Archive a completed change, syncing delta specs first when needed. Use when implementation and verification are done. Promotes approved decisions into persistent memory. No external binaries required.
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: No external binaries required. Works with any agent runtime.
|
|
6
6
|
metadata:
|
|
@@ -48,29 +48,28 @@ Check for delta specs at `aspec/changes/<name>/specs/`; if none, proceed without
|
|
|
48
48
|
|
|
49
49
|
Follow the `LOCKED: seed-freshness` block in `AGENTS.md` (single source of truth for this rule). If the `ancleto` CLI is available in this project, run `ancleto discovery --check` (read-only) and read its JSON `state` and `impact` to apply that rule; if the CLI is unavailable, skip this step.
|
|
50
50
|
|
|
51
|
-
### 4.
|
|
51
|
+
### 4. Preflight the promotion batch
|
|
52
|
+
|
|
53
|
+
Run this while the change is still in place, before moving anything. The shape of `## Decisiones` is defined in `agents/spec-writer.md`; reference it, do not restate it here.
|
|
54
|
+
|
|
55
|
+
1. Read `proposal.md` and collect the blocks under `## Decisiones`.
|
|
56
|
+
2. A candidate is a block with **its own `memory_key` and `Estado: aprobado`**. A one-line decision is never a candidate: it has no `memory_key` and no `Estado`. Promote only `decision`; a block classified as a permanent rule is not promoted from here.
|
|
57
|
+
3. **Preflight the whole batch before the first write:** own keys, keys unique within the batch, `Estado: aprobado`, refs present and resolvable, no `path:` that points inside `aspec/changes/<change-name>/` (it resolves now but stops resolving once the change is moved to `archive/`), and no session candidate (transcript, task state, session summary). Any violation ⇒ **zero writes**, a per-block report with its location in `proposal.md`, and **the change is not moved**; fix it and re-run the archive. If two blocks share a `memory_key` and differ in content or refs, reject the whole batch and report both.
|
|
58
|
+
4. **Zero candidates ⇒ zero memory writes and zero `@memory-keeper` delegations.** Do not ask the memory agent whether there is anything to store.
|
|
59
|
+
|
|
60
|
+
### 5. Perform the archive
|
|
52
61
|
|
|
53
62
|
Create `aspec/changes/archive/` if absent. Target: `YYYY-MM-DD-<change-name>` (current date).
|
|
54
63
|
|
|
55
64
|
- **Target exists**: fail, suggesting a rename or different date.
|
|
56
65
|
- **Otherwise**: delete scaffold-only files (e.g., `aspec/changes/<name>/context.md` — never preserved), then move the change directory to `aspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
57
66
|
|
|
58
|
-
###
|
|
67
|
+
### 6. Promote approved decisions into persistent memory
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
1. With valid candidates, delegate `@memory-keeper` **once** in Promotion mode with the change slug and the approved blocks verbatim. It writes one `recordDecision` per approved block with `content` of 1–3 sentences plus the `refs:` block, and reports per key.
|
|
70
|
+
2. If a write fails after the change was moved, report **archived, memory pending**, naming the `memory_key` and the reason, with a retry instruction. Never report "archived and memorized" when only the archive happened.
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
recordDecision({ memory_key: "<kebab-topic>", content: "<the decision>", justification: "<why this path>" })
|
|
65
|
-
```
|
|
66
|
-
- Standing rules or constraints discovered (future agents must keep following) → `recordRule`:
|
|
67
|
-
```
|
|
68
|
-
recordRule({ memory_key: "<kebab-topic>", content: "<the rule>", justification: "<evidence>" })
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Record only durable lessons — decisions whose reasons aren't visible in the result, constraints, verified workarounds. Never workflow meta. If nothing meets the bar, record nothing and say so.
|
|
72
|
-
|
|
73
|
-
### 6. Display summary
|
|
72
|
+
### 7. Display summary
|
|
74
73
|
|
|
75
74
|
```
|
|
76
75
|
## Archive Complete
|
|
@@ -78,9 +77,11 @@ Record only durable lessons — decisions whose reasons aren't visible in the re
|
|
|
78
77
|
**Change:** <change-name>
|
|
79
78
|
**Archived to:** aspec/changes/archive/YYYY-MM-DD-<name>/
|
|
80
79
|
**Specs:** ✓ Synced (or: No delta specs / Sync skipped)
|
|
81
|
-
**Memories
|
|
80
|
+
**Memories promoted:** <list, or "None">
|
|
82
81
|
```
|
|
83
82
|
|
|
83
|
+
If a promotion failed after the move, add `**Memory pending:** <memory_key> — <reason> (retry required)`. Never label the archive as memorized when a write is pending.
|
|
84
|
+
|
|
84
85
|
When the seed was `STALE`, add a `**Seed:**` line: `Regenerated` or `Stale — regeneration declined`.
|
|
85
86
|
|
|
86
87
|
If warnings applied (incomplete tasks, skipped sync, stale seed), show an "Archive Complete (with warnings)" variant listing them.
|
|
@@ -92,4 +93,6 @@ If warnings applied (incomplete tasks, skipped sync, stale seed), show an "Archi
|
|
|
92
93
|
- Seed freshness follows the `LOCKED: seed-freshness` rule in `AGENTS.md`.
|
|
93
94
|
- If the target archive directory exists, fail instead of overwriting.
|
|
94
95
|
- Delete scaffold-only files (`context.md`) before moving; never archive them.
|
|
96
|
+
- Promotion only uses `recordDecision`; it never invents a `memory_key`, never derives one from the change, and never promotes a `rule` from `## Decisiones`.
|
|
97
|
+
- Zero candidates ⇒ zero writes and zero `@memory-keeper` spawn.
|
|
95
98
|
- `recordRule`/`recordDecision` accept only `memory_key`, `content`, `justification`, `scope`. Never send `source`, `confidence`, `status` or `id` — the runtime manages those.
|
|
@@ -78,24 +78,19 @@ Ask once (runtime question tool when available): "Archive N changes?" — "Archi
|
|
|
78
78
|
|
|
79
79
|
Process in the determined order:
|
|
80
80
|
|
|
81
|
-
a. **
|
|
82
|
-
b. **
|
|
83
|
-
c. **
|
|
81
|
+
a. **Preflight promotions** before touching the change: apply the promotion gate to the blocks under `## Decisiones` of its `proposal.md` (shape defined in `agents/spec-writer.md`; reference it, do not restate it here). A block without its own `memory_key` and `Estado: aprobado` is not a candidate, and only `decision` is promoted (never a `rule`). Any key/refs/`Estado` violation, a session candidate (transcript, task state, session summary), or two blocks sharing a `memory_key` with different content or refs, rejects that change's batch: record it as failed with a per-block report of its location, do not sync its specs, do not move it, and continue with the rest of the batch.
|
|
82
|
+
b. **Sync specs** if delta specs exist and the resolution says so: apply the delta to `aspec/specs/<capability>/spec.md` (ADDED adds; MODIFIED updates preserving unmentioned scenarios; REMOVED deletes; RENAMED renames via `FROM:`/`TO:`). Track sync.
|
|
83
|
+
c. **Archive**: create `aspec/changes/archive/` if missing, delete scaffold-only files (`context.md`), move to `aspec/changes/archive/YYYY-MM-DD-<name>/`. If the target exists, fail that change (record the error) but continue.
|
|
84
|
+
d. **Track outcomes**: success, failed (with error), skipped.
|
|
84
85
|
|
|
85
|
-
### 9.
|
|
86
|
+
### 9. Promote approved decisions per change
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
Apply the archive promotion gate to each change archived in phase 8:
|
|
88
89
|
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
recordDecision({ memory_key: "<kebab-topic>", content: "<resolution>", justification: "<codebase evidence>" })
|
|
92
|
-
```
|
|
93
|
-
- Standing rules discovered (e.g., "these two capabilities must evolve together") → `recordRule`:
|
|
94
|
-
```
|
|
95
|
-
recordRule({ memory_key: "<kebab-topic>", content: "<the rule>", justification: "<evidence>" })
|
|
96
|
-
```
|
|
90
|
+
1. With valid candidates, delegate `@memory-keeper` once in Promotion mode for that change. It writes one `recordDecision` per approved block with `content` of 1–3 sentences plus the `refs:` block.
|
|
91
|
+
2. If a write fails after the move, report that change as **archived, memory pending** with the `memory_key` and the reason; this is the only post-move failure mode. Never report it as memorized.
|
|
97
92
|
|
|
98
|
-
|
|
93
|
+
**Aggregation:** if no selected change has candidates, the batch makes **zero `@memory-keeper` delegations**. Per-change pending memory does not block the rest of the batch.
|
|
99
94
|
|
|
100
95
|
### 10. Display summary
|
|
101
96
|
|
|
@@ -105,7 +100,8 @@ Record only what saves future investigation; never workflow meta. If nothing mee
|
|
|
105
100
|
Archived N changes: <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
106
101
|
Skipped M: <change-2> (incomplete; user declined)
|
|
107
102
|
Spec sync: N delta specs synced, M conflicts resolved
|
|
108
|
-
**Memories
|
|
103
|
+
**Memories promoted:** <list, or "None">
|
|
104
|
+
**Memory pending:** <change -> memory_key — reason, or "None">
|
|
109
105
|
```
|
|
110
106
|
|
|
111
107
|
## Guardrails
|
|
@@ -117,4 +113,6 @@ Spec sync: N delta specs synced, M conflicts resolved
|
|
|
117
113
|
- Show per-change status before one batch confirmation.
|
|
118
114
|
- Track and report all outcomes (success/skip/fail).
|
|
119
115
|
- Archive target: current date (`YYYY-MM-DD-<name>`); if it exists, fail that change but continue others.
|
|
116
|
+
- Promotion per change only uses `recordDecision`; it never invents a `memory_key` and never promotes a `rule` from `## Decisiones`.
|
|
117
|
+
- Zero candidates across the batch ⇒ zero `@memory-keeper` delegations.
|
|
120
118
|
- `recordRule`/`recordDecision` accept only `memory_key`, `content`, `justification`, `scope`. Never send `source`, `confidence`, `status` or `id` — the runtime manages those.
|
|
@@ -46,20 +46,16 @@ A non-canonical spec cannot be matched for coverage or merged reliably, so repor
|
|
|
46
46
|
|
|
47
47
|
**Patterns:** review new code against project patterns (naming, dirs, style). Significant deviation → SUGGESTION: "Code pattern deviation: `<details>`" + "Consider following project pattern: `<example>`".
|
|
48
48
|
|
|
49
|
-
### 5. Capture findings into persistent memory
|
|
49
|
+
### 5. Capture durable findings into persistent memory
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Verify is not a second promotion point. **Do not promote the change's `## Decisiones`**: the archive gate owns that and will promote the approved blocks when the change is archived. The shape of `## Decisiones` is defined in `agents/spec-writer.md`; reference it, do not restate it here.
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
```
|
|
55
|
-
recordRule({ memory_key: "<kebab-topic>", content: "<the rule>", justification: "<evidence>" })
|
|
56
|
-
```
|
|
57
|
-
- Design decisions/trade-offs and why → `recordDecision`:
|
|
58
|
-
```
|
|
59
|
-
recordDecision({ memory_key: "<kebab-topic>", content: "<the decision>", justification: "<reason>" })
|
|
60
|
-
```
|
|
53
|
+
Only record a durable finding that is **not** a decision of this change and that survives the change — a standing rule, a constraint, or a reusable lesson revealed by a WARNING/CRITICAL:
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
- Standing rules/constraints/conventions → `recordRule` with a `refs:` block.
|
|
56
|
+
- Durable, non-decision lessons → `recordDecision` with a `refs:` block.
|
|
57
|
+
|
|
58
|
+
Every write carries a normalized `refs:` block; the engine rejects a capture write without it. A `decision` requires at least one `spec:` or `change:` ref; a `rule` requires at least one `path:`, `spec:` or `change:` ref. Record only what saves future investigation, never workflow meta and never the outcome of the verification itself. If nothing meets the bar, record nothing and say so.
|
|
63
59
|
|
|
64
60
|
### 6. Verification Report
|
|
65
61
|
|
|
@@ -101,4 +97,5 @@ Record only what saves future investigation, not the outcome. If nothing meets t
|
|
|
101
97
|
|
|
102
98
|
- Reference code as `file.ts:123`.
|
|
103
99
|
- No vague suggestions ("consider reviewing") — every issue needs a specific recommendation.
|
|
100
|
+
- Never promote `## Decisiones` from verify; that is the archive's promotion gate.
|
|
104
101
|
- `recordRule`/`recordDecision` accept only `memory_key`, `content`, `justification`, `scope`. Never send `source`, `confidence`, `status` or `id` — the runtime manages those.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
3
4
|
import { openDatabase, checkpointDatabase } from './database.js'
|
|
4
5
|
import { readTopologySummary } from '../discovery.js'
|
|
5
6
|
|
|
@@ -72,6 +73,243 @@ function publicNode(row) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// --- MEM-03: refs normalizadas y rechazo de secretos en el camino de escritura ---
|
|
77
|
+
//
|
|
78
|
+
// Reglas fijas del change:
|
|
79
|
+
// - La validacion de refs se aplica a toda escritura que pasa por `recordNode`,
|
|
80
|
+
// con una unica exencion: la procedencia resuelta por el runtime es 'runtime'.
|
|
81
|
+
// - El rechazo de secretos es incondicional. Nunca repite el valor detectado.
|
|
82
|
+
// - La raiz del proyecto se deriva de `context.projectRoot` o de la ubicacion de
|
|
83
|
+
// la DB (`.ancletorc` hacia arriba), NUNCA de `process.cwd()`. No se importa
|
|
84
|
+
// `working-context.js` para no crear un ciclo (ese modulo ya importa este).
|
|
85
|
+
|
|
86
|
+
const REF_KINDS = new Set(['spec', 'change', 'path', 'commit', 'workitem'])
|
|
87
|
+
|
|
88
|
+
const SECRET_PATTERNS = [
|
|
89
|
+
/\bsk-[A-Za-z0-9_-]{8,}/,
|
|
90
|
+
/\bghp_[A-Za-z0-9]{8,}/,
|
|
91
|
+
/\bAKIA[0-9A-Z]{12,}/,
|
|
92
|
+
/authorization\s*:\s*bearer\s+\S+/i,
|
|
93
|
+
/\b[A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD)\s*=\s*[^\s"'`]+/i,
|
|
94
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
function looksLikeSecret(text) {
|
|
98
|
+
if (!text) return false
|
|
99
|
+
return SECRET_PATTERNS.some((re) => re.test(text))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeHeading(text) {
|
|
103
|
+
return String(text).trim().replace(/\s+/g, ' ')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function canonicalHeading(text) {
|
|
107
|
+
return normalizeHeading(text)
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.normalize('NFD')
|
|
110
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
111
|
+
.replace(/`/g, '')
|
|
112
|
+
.replace(/\*\*/g, '')
|
|
113
|
+
.replace(/\s+/g, ' ')
|
|
114
|
+
.trim()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseRef(raw) {
|
|
118
|
+
if (raw.startsWith('spec:')) {
|
|
119
|
+
const value = raw.slice('spec:'.length)
|
|
120
|
+
const hash = value.indexOf('#')
|
|
121
|
+
if (hash <= 0 || hash === value.length - 1) return null
|
|
122
|
+
const capability = value.slice(0, hash).trim()
|
|
123
|
+
const heading = value.slice(hash + 1).trim()
|
|
124
|
+
if (!capability || !heading || /[\\/]/.test(capability) || capability.includes('..')) return null
|
|
125
|
+
return { kind: 'spec', capability, heading, raw }
|
|
126
|
+
}
|
|
127
|
+
if (raw.startsWith('change:')) {
|
|
128
|
+
const slug = raw.slice('change:'.length).trim()
|
|
129
|
+
if (!slug || /[\s\\/]/.test(slug) || slug.includes('..')) return null
|
|
130
|
+
return { kind: 'change', slug, raw }
|
|
131
|
+
}
|
|
132
|
+
if (raw.startsWith('path:')) {
|
|
133
|
+
const value = raw.slice('path:'.length).trim()
|
|
134
|
+
if (!value) return null
|
|
135
|
+
return { kind: 'path', value, raw }
|
|
136
|
+
}
|
|
137
|
+
if (raw.startsWith('commit:')) {
|
|
138
|
+
const sha = raw.slice('commit:'.length).trim()
|
|
139
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return null
|
|
140
|
+
return { kind: 'commit', sha, raw }
|
|
141
|
+
}
|
|
142
|
+
if (raw.startsWith('workitem:')) {
|
|
143
|
+
const rest = raw.slice('workitem:'.length)
|
|
144
|
+
const sep = rest.indexOf(':')
|
|
145
|
+
if (sep <= 0 || sep === rest.length - 1) return null
|
|
146
|
+
return { kind: 'workitem', tracker: rest.slice(0, sep), key: rest.slice(sep + 1), raw }
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// El bloque empieza en una linea `refs:` al inicio de linea y termina antes de la
|
|
152
|
+
// primera linea en blanco o de texto no indentado. Una linea indentada que no es
|
|
153
|
+
// item de lista, un item con gramatica invalida o un segundo bloque `refs:` se
|
|
154
|
+
// reportan como `error`: fail-closed, nunca se ignora (un segundo bloque dejaria
|
|
155
|
+
// refs sin validar).
|
|
156
|
+
function refsBlockEnd(lines, start) {
|
|
157
|
+
let end = start + 1
|
|
158
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
159
|
+
const line = lines[i]
|
|
160
|
+
if (line.trim() === '') return i
|
|
161
|
+
if (/^\s/.test(line)) {
|
|
162
|
+
end = i + 1
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
return i
|
|
166
|
+
}
|
|
167
|
+
return end
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseRefsBlock(content) {
|
|
171
|
+
const lines = String(content).split(/\r?\n/)
|
|
172
|
+
const starts = []
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
if (/^refs:\s*$/.test(lines[i])) starts.push(i)
|
|
175
|
+
}
|
|
176
|
+
if (starts.length === 0) return null
|
|
177
|
+
const start = starts[0]
|
|
178
|
+
const end = refsBlockEnd(lines, start)
|
|
179
|
+
if (starts.length > 1) return { refs: [], error: null, blocks: starts.length, start, end }
|
|
180
|
+
|
|
181
|
+
const refs = []
|
|
182
|
+
for (let i = start + 1; i < end; i++) {
|
|
183
|
+
const line = lines[i]
|
|
184
|
+
const item = /^\s*-\s+(.*)$/.exec(line)
|
|
185
|
+
if (item) {
|
|
186
|
+
const raw = item[1].trim()
|
|
187
|
+
const ref = raw ? parseRef(raw) : null
|
|
188
|
+
if (!ref) return { refs, error: line.trim(), start, end }
|
|
189
|
+
refs.push(ref)
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
return { refs, error: line.trim(), start, end }
|
|
193
|
+
}
|
|
194
|
+
return { refs, error: null, start, end }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// El bloque proactivo muestra solo la prosa previa al primer `refs:`: ese bloque
|
|
198
|
+
// es provenance para resolucion bajo demanda y todo lo que sigue al bloque (el
|
|
199
|
+
// parser lo considera terminal) no es prosa validada. Recortar por el `start`
|
|
200
|
+
// elimina tambien los bloques `refs:` adicionales sin logica extra.
|
|
201
|
+
function renderRuleContent(content) {
|
|
202
|
+
const parsed = parseRefsBlock(content)
|
|
203
|
+
if (!parsed) return content
|
|
204
|
+
const lines = String(content).split(/\r?\n/)
|
|
205
|
+
return lines.slice(0, parsed.start).join('\n').trimEnd()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function resolveProjectRoot(context, dbPath) {
|
|
209
|
+
const fromContext = context && typeof context.projectRoot === 'string' ? context.projectRoot.trim() : ''
|
|
210
|
+
if (fromContext) return fromContext
|
|
211
|
+
if (!dbPath) return null
|
|
212
|
+
const candidate = dirname(dirname(dbPath))
|
|
213
|
+
for (let dir = candidate; ; dir = dirname(dir)) {
|
|
214
|
+
if (existsSync(join(dir, '.ancletorc'))) return dir
|
|
215
|
+
const parent = dirname(dir)
|
|
216
|
+
if (parent === dir) break
|
|
217
|
+
}
|
|
218
|
+
if (existsSync(join(candidate, '.ancleto'))) return candidate
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveSpecRef(ref, root, prefix) {
|
|
223
|
+
const specPath = join(root, 'aspec', 'specs', ref.capability, 'spec.md')
|
|
224
|
+
if (!existsSync(specPath)) {
|
|
225
|
+
throw new Error(`${prefix} referencia un spec inexistente: "${ref.raw}"`)
|
|
226
|
+
}
|
|
227
|
+
const headings = readFileSync(specPath, 'utf8')
|
|
228
|
+
.split(/\r?\n/)
|
|
229
|
+
.map((line) => /^###\s+Requirement:\s*(.*)$/.exec(line))
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.map((match) => match[1])
|
|
232
|
+
|
|
233
|
+
// Un heading exacto no cortocircuita la unicidad: se recolectan todos los
|
|
234
|
+
// bloques que matcheen por forma exacta o canonizada, deduplicados por
|
|
235
|
+
// identidad de bloque (un heading exacto tambien canoniza a si mismo). La
|
|
236
|
+
// ambiguedad se reporta, nunca se resuelve eligiendo el primero.
|
|
237
|
+
const target = canonicalHeading(ref.heading)
|
|
238
|
+
const matches = headings.filter(
|
|
239
|
+
(h) => normalizeHeading(h) === normalizeHeading(ref.heading) || canonicalHeading(h) === target
|
|
240
|
+
)
|
|
241
|
+
if (matches.length === 1) return
|
|
242
|
+
if (matches.length === 0) throw new Error(`${prefix} referencia un spec inexistente: "${ref.raw}"`)
|
|
243
|
+
throw new Error(`${prefix} referencia un spec ambiguo (${matches.length} coincidencias): "${ref.raw}"`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function escapeRegExp(text) {
|
|
247
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveChangeRef(ref, root, prefix) {
|
|
251
|
+
const matches = []
|
|
252
|
+
const active = join(root, 'aspec', 'changes', ref.slug)
|
|
253
|
+
if (existsSync(active) && statSync(active).isDirectory()) matches.push(active)
|
|
254
|
+
|
|
255
|
+
const archiveDir = join(root, 'aspec', 'changes', 'archive')
|
|
256
|
+
if (existsSync(archiveDir)) {
|
|
257
|
+
// El nombre archivado es exactamente `YYYY-MM-DD-<slug>`: un sufijo suelto
|
|
258
|
+
// resolveria al archive de otro change (`export-import` -> `...-cross-machine-export-import`).
|
|
259
|
+
const archivedName = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-${escapeRegExp(ref.slug)}$`)
|
|
260
|
+
for (const name of readdirSync(archiveDir)) {
|
|
261
|
+
if (!archivedName.test(name)) continue
|
|
262
|
+
const full = join(archiveDir, name)
|
|
263
|
+
if (statSync(full).isDirectory()) matches.push(full)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (matches.length === 0) throw new Error(`${prefix} referencia un change inexistente: "${ref.raw}"`)
|
|
268
|
+
if (matches.length > 1) throw new Error(`${prefix} referencia un change ambiguo (${matches.length} coincidencias): "${ref.raw}"`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resolvePathRef(ref, root, prefix) {
|
|
272
|
+
const rawPath = ref.value.replace(/:\d+$/, '')
|
|
273
|
+
if (!rawPath || isAbsolute(rawPath) || /^[A-Za-z]:/.test(rawPath)) {
|
|
274
|
+
throw new Error(`${prefix} declara un path absoluto prohibido: "${ref.raw}"`)
|
|
275
|
+
}
|
|
276
|
+
const segments = rawPath.split(/[\\/]+/).filter(Boolean)
|
|
277
|
+
if (segments.includes('..')) throw new Error(`${prefix} declara un path con ../: "${ref.raw}"`)
|
|
278
|
+
const full = join(root, rawPath)
|
|
279
|
+
if (!existsSync(full) || !statSync(full).isFile()) {
|
|
280
|
+
throw new Error(`${prefix} declara un path inexistente: "${ref.raw}"`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateRefsForWrite({ memory_key, type, content, projectRoot }) {
|
|
285
|
+
const prefix = `refs: la ${type} "${memory_key}"`
|
|
286
|
+
const parsed = parseRefsBlock(content)
|
|
287
|
+
if (!parsed) throw new Error(`${prefix} requiere un bloque refs: con al menos una referencia`)
|
|
288
|
+
if (parsed.blocks) {
|
|
289
|
+
throw new Error(`${prefix} declara ${parsed.blocks} bloques refs: y se permite exactamente uno`)
|
|
290
|
+
}
|
|
291
|
+
if (parsed.error) throw new Error(`${prefix} tiene una ref invalida en el bloque refs: "${parsed.error}"`)
|
|
292
|
+
if (parsed.refs.length === 0) throw new Error(`${prefix} tiene un bloque refs: vacio`)
|
|
293
|
+
if (!projectRoot) throw new Error(`${prefix} no puede resolver refs: no se encontro la raiz del proyecto`)
|
|
294
|
+
|
|
295
|
+
for (const ref of parsed.refs) {
|
|
296
|
+
if (!REF_KINDS.has(ref.kind)) throw new Error(`${prefix} usa un prefijo de ref desconocido: "${ref.raw}"`)
|
|
297
|
+
if (ref.kind === 'spec') resolveSpecRef(ref, projectRoot, prefix)
|
|
298
|
+
else if (ref.kind === 'change') resolveChangeRef(ref, projectRoot, prefix)
|
|
299
|
+
else if (ref.kind === 'path') resolvePathRef(ref, projectRoot, prefix)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const hasSpec = parsed.refs.some((r) => r.kind === 'spec')
|
|
303
|
+
const hasChange = parsed.refs.some((r) => r.kind === 'change')
|
|
304
|
+
const hasPath = parsed.refs.some((r) => r.kind === 'path')
|
|
305
|
+
if (type === 'decision' && !hasSpec && !hasChange) {
|
|
306
|
+
throw new Error(`${prefix} requiere al menos una ref spec:<cap>#<heading> o change:<slug>`)
|
|
307
|
+
}
|
|
308
|
+
if (type === 'rule' && !hasPath && !hasSpec && !hasChange) {
|
|
309
|
+
throw new Error(`${prefix} requiere al menos una ref path:, spec: o change:`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
75
313
|
export function formatTopologyBlock(topo) {
|
|
76
314
|
if (!topo) return null
|
|
77
315
|
const entries = Object.entries(topo.tree_summary)
|
|
@@ -85,20 +323,20 @@ export function defaultMemoryDbPath(cwd = process.cwd()) {
|
|
|
85
323
|
}
|
|
86
324
|
|
|
87
325
|
export function createMemoryEngine(dbPath = defaultMemoryDbPath()) {
|
|
88
|
-
return buildEngine(openDatabase(dbPath))
|
|
326
|
+
return buildEngine(openDatabase(dbPath), dbPath)
|
|
89
327
|
}
|
|
90
328
|
|
|
91
329
|
// Apertura read-only: sin migraciones ni escrituras. Pensada para inspeccionar
|
|
92
330
|
// la memoria sin tocar el WAL ni modificar archivos.
|
|
93
331
|
export function createReadonlyMemoryEngine(dbPath = defaultMemoryDbPath()) {
|
|
94
|
-
return buildReadonlyEngine(openDatabase(dbPath, { migrate: false, readonly: true }))
|
|
332
|
+
return buildReadonlyEngine(openDatabase(dbPath, { migrate: false, readonly: true }), dbPath)
|
|
95
333
|
}
|
|
96
334
|
|
|
97
335
|
// Misma superficie que el engine de escritura. `merge` solo puede planificar:
|
|
98
336
|
// una corrida real se rechaza antes de tocar la conexion read-only, y el plan
|
|
99
337
|
// sale de la validacion compartida (`validateMerge`), identico al de escritura.
|
|
100
|
-
function buildReadonlyEngine(db) {
|
|
101
|
-
const engine = buildEngine(db)
|
|
338
|
+
function buildReadonlyEngine(db, dbPath) {
|
|
339
|
+
const engine = buildEngine(db, dbPath)
|
|
102
340
|
return {
|
|
103
341
|
...engine,
|
|
104
342
|
merge({ target, sources, dryRun = false } = {}) {
|
|
@@ -108,7 +346,7 @@ function buildReadonlyEngine(db) {
|
|
|
108
346
|
}
|
|
109
347
|
}
|
|
110
348
|
|
|
111
|
-
function buildEngine(db) {
|
|
349
|
+
function buildEngine(db, dbPath) {
|
|
112
350
|
|
|
113
351
|
const findActive = db.prepare(`SELECT id, type FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
|
|
114
352
|
const findActiveRow = db.prepare(`SELECT id, created_at FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
|
|
@@ -147,7 +385,7 @@ function buildEngine(db) {
|
|
|
147
385
|
let block = `<ProjectMemoryRules>\n${UNTRUSTED_LABEL}`
|
|
148
386
|
let includedCount = 0
|
|
149
387
|
for (const r of rows) {
|
|
150
|
-
const line = `- [${r.memory_key}] ${r.content}`
|
|
388
|
+
const line = `- [${r.memory_key}] ${renderRuleContent(r.content)}`
|
|
151
389
|
const candidate = `${block}\n${line}`
|
|
152
390
|
if (candidate.length > maxChars) break
|
|
153
391
|
block = candidate
|
|
@@ -197,14 +435,14 @@ function buildEngine(db) {
|
|
|
197
435
|
return []
|
|
198
436
|
}
|
|
199
437
|
|
|
200
|
-
// Procedencia de escritura (runtime-owned
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
438
|
+
// Procedencia de escritura (runtime-owned): `source` se resuelve exclusivamente
|
|
439
|
+
// desde `context.source || 'runtime'` y NUNCA desde `input` (un `source` forjado
|
|
440
|
+
// en los args del caller se ignora, igual que `id`/`status`/`confidence`).
|
|
441
|
+
// Vocabulario: `mcp:ancleto-memory` (tools MCP), `cli:import` (import del CLI),
|
|
442
|
+
// `runtime` (default programatico y fixtures), `agent:<rol>` (solo si el runtime
|
|
443
|
+
// confiable lo provee, nunca el caller). La procedencia decide una unica cosa:
|
|
444
|
+
// si la escritura pasa por la validacion de refs. La exencion es el valor exacto
|
|
445
|
+
// 'runtime'; cualquier otro valor se valida (fail-closed).
|
|
208
446
|
function recordNode(input, context = {}) {
|
|
209
447
|
const memory_key = String(input.memory_key || '').trim()
|
|
210
448
|
const type = input.type
|
|
@@ -220,6 +458,22 @@ function buildEngine(db) {
|
|
|
220
458
|
const confidence = Math.min(Math.max(Number(context.confidence ?? 1), 0), 1)
|
|
221
459
|
const created_at = String(input.createdAt || new Date().toISOString())
|
|
222
460
|
|
|
461
|
+
// Validacion antes de abrir la transaccion: un rechazo no escribe ni supersede.
|
|
462
|
+
if (looksLikeSecret(content)) {
|
|
463
|
+
throw new Error(`contenido rechazado en "${memory_key}": parece contener una credencial`)
|
|
464
|
+
}
|
|
465
|
+
if (looksLikeSecret(justification)) {
|
|
466
|
+
throw new Error(`justificacion rechazada en "${memory_key}": parece contener una credencial`)
|
|
467
|
+
}
|
|
468
|
+
if (source !== 'runtime') {
|
|
469
|
+
validateRefsForWrite({
|
|
470
|
+
memory_key,
|
|
471
|
+
type,
|
|
472
|
+
content,
|
|
473
|
+
projectRoot: resolveProjectRoot(context, dbPath)
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
223
477
|
db.exec('BEGIN IMMEDIATE')
|
|
224
478
|
let prev = null
|
|
225
479
|
try {
|