memorix 1.2.0 → 1.2.1

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.
@@ -120,6 +120,8 @@ The built-in Lite provider indexes common code files with lightweight file, symb
120
120
  ```bash
121
121
  memorix knowledge init --mode local
122
122
  memorix knowledge status
123
+ memorix knowledge claims
124
+ memorix knowledge review --id <claim-id> --review approved --detail "checked current source evidence"
123
125
  memorix knowledge compile
124
126
  memorix knowledge lint
125
127
  memorix knowledge apply --proposal <id>
@@ -128,7 +130,7 @@ memorix knowledge workflow select --task "prepare a release"
128
130
  memorix knowledge workflow preview --id <workflow-id> --agent codex
129
131
  ```
130
132
 
131
- The advanced MCP action tool is `memorix_knowledge`. It is registered only in the `team` and `full` tool profiles so normal agents keep the compact micro/lite tool surface. Its actions are `workspace_init`, `status`, `compile`, `lint`, `proposal_apply`, `workflow_import`, `workflow_list`, `workflow_select`, `workflow_preview`, `workflow_apply`, and `workflow_run`. Use it only when an agent is explicitly managing the reviewable Knowledge Workspace; ordinary coding work should stay on `memorix_project_context`.
133
+ The advanced MCP action tool is `memorix_knowledge`. It is registered only in the `team` and `full` tool profiles so normal agents keep the compact micro/lite tool surface. Its actions are `workspace_init`, `status`, `claim_list`, `claim_review`, `compile`, `lint`, `proposal_apply`, `workflow_import`, `workflow_list`, `workflow_select`, `workflow_preview`, `workflow_apply`, and `workflow_run`. Explicit agent observations become `needs-review` claim candidates: check their source evidence, then use `claim_review` with a reason to approve or reject them. Only approved claims can be compiled into publishable Knowledge Workspace pages. Ordinary coding work should stay on `memorix_project_context`.
132
134
 
133
135
  ### Cross-Agent Handoff Receipt
134
136
 
@@ -271,6 +271,10 @@ to make sure provenance diagnostics match runtime behavior.
271
271
 
272
272
  ## 9. Release Workflow
273
273
 
274
+ The repository's canonical workflow is [Memorix release](knowledge/workflows/memorix-release.md).
275
+ It is the versioned source for agents and maintainers; its checks supplement,
276
+ rather than replace, the explicit maintainer approval required to publish.
277
+
274
278
  Recommended release flow:
275
279
 
276
280
  1. update docs and version metadata
package/docs/SETUP.md CHANGED
@@ -276,12 +276,18 @@ Manual stdio shape:
276
276
  "mcpServers": {
277
277
  "memorix": {
278
278
  "command": "memorix",
279
- "args": ["serve"]
279
+ "args": ["serve"],
280
+ "alwaysLoad": true
280
281
  }
281
282
  }
282
283
  }
283
284
  ```
284
285
 
286
+ Keep `alwaysLoad: true` for a manually managed Claude Code entry so its small
287
+ Memorix tool set is available during print-mode startup. `memorix doctor agents
288
+ --agent claude` reports a missing eager-load setting, and `memorix repair agents
289
+ --agent claude` restores it without replacing unrelated MCP servers.
290
+
285
291
  ### Cursor
286
292
 
287
293
  Project config: `.cursor/mcp.json`
@@ -0,0 +1,57 @@
1
+ ---
2
+ id: memorix-release
3
+ title: Memorix release
4
+ description: Prepare a Memorix release with evidence, package verification, and explicit maintainer approval.
5
+ status: active
6
+ version: 1
7
+ taskLenses: [release]
8
+ triggers: [release, publish, npm, version, changelog]
9
+ allowedAgents: [codex, claude-code, cursor, windsurf, copilot, antigravity, gemini-cli, openclaw, hermes, omp, kiro, opencode, trae]
10
+ verificationGates:
11
+ - Version and changelog match the release scope
12
+ - npm run lint passes
13
+ - npm run build passes
14
+ - npm test passes
15
+ - Focused live MCP smoke passes when server behavior changed
16
+ - Package smoke passes
17
+ - CI passes on the release commit
18
+ - Maintainer approval is explicit before publishing
19
+ ---
20
+
21
+ ## Inspect
22
+
23
+ Read the current version, changelog, Git state, open release risks, and the
24
+ diff that will ship. Do not treat a release tag, an old test run, or a local
25
+ token as proof that this exact commit is ready.
26
+
27
+ ## Prepare
28
+
29
+ Update version metadata, changelog, and user-facing documentation only for
30
+ changes that are actually included. Keep package workspace visibility and npm
31
+ access settings unchanged unless the release specifically changes them.
32
+
33
+ ## Verify
34
+
35
+ Run `npm run lint`, `npm run build`, and `npm test`. Run focused tests for the
36
+ changed surface. When MCP behavior changed, use a real isolated stdio client to
37
+ exercise the affected tool path. Inspect `npm pack --dry-run --json` and run a
38
+ package smoke from the packed artifact when packaging changed.
39
+
40
+ ## Review
41
+
42
+ Record what passed, what was not run, and any remaining risk. Confirm that the
43
+ release commit has passing CI. A workflow may prepare evidence, but publishing
44
+ requires explicit maintainer approval.
45
+
46
+ ## Publish
47
+
48
+ After explicit maintainer approval, publish through the approved release path.
49
+ Never put credentials in the repository, release notes, workflow files, or
50
+ command output. Verify the published package version without exposing tokens.
51
+
52
+ ## Follow Up
53
+
54
+ Check a fresh install or `npx` invocation, create or update the GitHub release
55
+ if appropriate, and reply to affected issue reporters with the concrete fix and
56
+ version. Store the verification evidence as project knowledge rather than an
57
+ unstructured chat note.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -3,7 +3,7 @@ import { CodeGraphStore } from '../../codegraph/store.js';
3
3
  import { refreshProjectLite } from '../../codegraph/lite-provider.js';
4
4
  import { assembleContextPackForTask, attachTaskWorkset, buildContextPackPrompt } from '../../codegraph/context-pack.js';
5
5
  import { backfillMissingObservationCodeRefs } from '../../codegraph/binder.js';
6
- import { collectCurrentProjectFacts } from '../../codegraph/current-facts.js';
6
+ import { collectCurrentProjectFacts, formatGitFact } from '../../codegraph/current-facts.js';
7
7
  import { resolveTaskLens } from '../../codegraph/task-lens.js';
8
8
  import { getExternalCodeGraphContext, inspectExternalCodeGraph } from '../../codegraph/external-provider.js';
9
9
  import type { CodeGraphProviderQuality } from '../../codegraph/types.js';
@@ -71,8 +71,7 @@ function compactFacts(project: { rootPath: string }): { facts: string[]; dirty:
71
71
  facts.push('Latest changelog: ' + current.latestChangelog.version
72
72
  + (current.latestChangelog.date ? ' (' + current.latestChangelog.date + ')' : ''));
73
73
  }
74
- facts.push('Git: ' + (current.git.branch ? 'branch ' + current.git.branch + ', ' : '')
75
- + (current.git.dirty ? 'dirty worktree' : 'clean worktree'));
74
+ facts.push(formatGitFact(current.git));
76
75
  return { facts, dirty: current.git.dirty };
77
76
  }
78
77
 
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from 'citty';
2
2
  import { CodeGraphStore } from '../../codegraph/store.js';
3
3
  import { ClaimStore } from '../../knowledge/claim-store.js';
4
+ import { reviewClaim } from '../../knowledge/claims.js';
4
5
  import { applyKnowledgeProposal, compileKnowledgeWorkspace, lintKnowledgeWorkspace } from '../../knowledge/wiki.js';
5
6
  import { initializeKnowledgeWorkspace, loadKnowledgeWorkspace } from '../../knowledge/workspace.js';
6
7
  import { KnowledgeWorkspaceStore } from '../../knowledge/workspace-store.js';
@@ -28,6 +29,8 @@ function usage(): string {
28
29
  ' memorix knowledge init [--mode local]',
29
30
  ' memorix knowledge init --mode versioned --path C:\\project\\docs\\knowledge',
30
31
  ' memorix knowledge status',
32
+ ' memorix knowledge claims',
33
+ ' memorix knowledge review --id <claim-id> --review approved --detail "checked current source evidence"',
31
34
  ' memorix knowledge compile',
32
35
  ' memorix knowledge lint',
33
36
  ' memorix knowledge apply --proposal <id> [--force]',
@@ -57,6 +60,11 @@ function workflowVerdict(value: unknown): 'passed' | 'failed' | 'not-run' | unde
57
60
  throw new Error('workflow verdict must be passed, failed, or not-run');
58
61
  }
59
62
 
63
+ function claimReviewState(value: unknown): 'approved' | 'rejected' {
64
+ if (value === 'approved' || value === 'rejected') return value;
65
+ throw new Error('claim review must be approved or rejected');
66
+ }
67
+
60
68
  function evidenceList(value: unknown): string[] {
61
69
  if (typeof value !== 'string' || !value.trim()) return [];
62
70
  return [...new Set(value.split(',').map(item => item.trim()).filter(Boolean))];
@@ -81,6 +89,8 @@ export default defineCommand({
81
89
  failure: { type: 'string', description: 'Sanitized failure reason for a workflow run' },
82
90
  snapshot: { type: 'string', description: 'Starting code snapshot id for a workflow run' },
83
91
  evidence: { type: 'string', description: 'Comma-separated evidence ids selected for a workflow run' },
92
+ review: { type: 'string', description: 'Claim review verdict: approved or rejected' },
93
+ detail: { type: 'string', description: 'Evidence check performed for a claim review' },
84
94
  json: { type: 'boolean', description: 'Emit machine-readable JSON output' },
85
95
  },
86
96
  run: async ({ args }) => {
@@ -120,6 +130,36 @@ export default defineCommand({
120
130
  const workspaceStore = new KnowledgeWorkspaceStore();
121
131
  await workspaceStore.init(dataDir);
122
132
 
133
+ if (action === 'claims') {
134
+ const claims = claimStore.listClaims(project.id, { limit: 100 });
135
+ emitResult(
136
+ { project, workspace, claims },
137
+ 'Claims: ' + claims.length + ' total, ' + claims.filter(claim => claim.reviewState === 'needs-review').length + ' awaiting review.',
138
+ asJson,
139
+ );
140
+ return;
141
+ }
142
+
143
+ if (action === 'review') {
144
+ const claimId = requiredText(args.id, 'claim id');
145
+ const existing = claimStore.getClaim(claimId);
146
+ if (!existing || existing.projectId !== project.id) {
147
+ emitError('Claim was not found for this knowledge workspace.', asJson);
148
+ return;
149
+ }
150
+ const claim = reviewClaim(claimStore, {
151
+ claimId,
152
+ reviewState: claimReviewState(args.review),
153
+ detail: requiredText(args.detail, 'review detail'),
154
+ });
155
+ emitResult(
156
+ { project, workspace, claim },
157
+ 'Claim ' + claim.id + ' marked ' + claim.reviewState + '.',
158
+ asJson,
159
+ );
160
+ return;
161
+ }
162
+
123
163
  if (action === 'workflow') {
124
164
  const workflowAction = (positional[1] || 'list').toLowerCase();
125
165
  const workflowStore = new WorkflowStore();
@@ -23,6 +23,7 @@ import { defineCommand } from 'citty';
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
24
  import type { ObservationStore } from '../../store/obs-store.js';
25
25
  import { resolveToolProfile } from '../../server/tool-profile.js';
26
+ import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
26
27
 
27
28
  export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000;
28
29
 
@@ -764,6 +765,7 @@ export default defineCommand({
764
765
  source?: string;
765
766
  commitHash?: string;
766
767
  filesModified?: string[];
768
+ relatedEntities?: string[];
767
769
  status?: string;
768
770
  importance?: number;
769
771
  accessCount?: number;
@@ -860,12 +862,7 @@ export default defineCommand({
860
862
  } catch { /* best effort */ }
861
863
 
862
864
  // Project-scoped graph counts (must match /graph and /export)
863
- const projectEntityNames = new Set(
864
- observations.filter(o => o.entityName).map(o => o.entityName!)
865
- );
866
- const projectEntities = graph.entities.filter((e: any) => projectEntityNames.has(e.name));
867
- const projectEntitySet = new Set(projectEntities.map((e: any) => e.name));
868
- const projectRelations = graph.relations.filter((r: any) => projectEntitySet.has(r.from) && projectEntitySet.has(r.to));
865
+ const projectGraph = scopeKnowledgeGraphToProject(graph, observations);
869
866
 
870
867
  let maintenance = { total: 0, pending: 0, running: 0, retrying: 0, completed: 0, failed: 0 };
871
868
  let lifecycle: unknown;
@@ -877,8 +874,8 @@ export default defineCommand({
877
874
  } catch { /* optional maintenance diagnostics */ }
878
875
 
879
876
  sendJson({
880
- entities: projectEntities.length,
881
- relations: projectRelations.length,
877
+ entities: projectGraph.entities.length,
878
+ relations: projectGraph.relations.length,
882
879
  observations: observations.length,
883
880
  nextId,
884
881
  typeCounts,
@@ -930,19 +927,9 @@ export default defineCommand({
930
927
  await initGraphStore(graphDataDir);
931
928
  const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
932
929
 
933
- // Project-scope the graph: only include entities that have observations in this project
934
- const allObs = await loadDashboardProjectObservations(graphDataDir, graphProjectId, 'active') as Array<{ entityName?: string }>;
935
- const projectEntityNames = new Set(
936
- allObs
937
- .filter(o => o.entityName)
938
- .map(o => o.entityName!)
939
- );
940
-
941
- const entities = fullGraph.entities.filter(e => projectEntityNames.has(e.name));
942
- const entityNameSet = new Set(entities.map(e => e.name));
943
- const relations = fullGraph.relations.filter(r => entityNameSet.has(r.from) && entityNameSet.has(r.to));
944
-
945
- sendJson({ entities, relations });
930
+ const allObs = await loadDashboardProjectObservations(graphDataDir, graphProjectId, 'active');
931
+ const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
932
+ sendJson({ entities: scoped.entities, relations: scoped.relations });
946
933
  return;
947
934
  }
948
935
 
@@ -1012,24 +999,15 @@ export default defineCommand({
1012
999
  const allObs = await loadDashboardProjectObservations(kgDataDir, kgProjectId, 'active');
1013
1000
  const skills = await getMiniSkillStore().loadByProject(kgProjectId);
1014
1001
 
1015
- // Project-scope graph store (same logic as /api/graph)
1016
1002
  const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1017
- const graphObs = await loadDashboardProjectObservations(kgDataDir, kgProjectId, 'active') as Array<{ entityName?: string }>;
1018
- const projectEntityNames = new Set(
1019
- graphObs
1020
- .filter(o => o.entityName)
1021
- .map(o => o.entityName!)
1022
- );
1023
- const scopedEntities = fullGraph.entities.filter(e => projectEntityNames.has(e.name));
1024
- const scopedEntityNameSet = new Set(scopedEntities.map(e => e.name));
1025
- const scopedRelations = fullGraph.relations.filter(r => scopedEntityNameSet.has(r.from) && scopedEntityNameSet.has(r.to));
1003
+ const scoped = scopeKnowledgeGraphToProject(fullGraph, allObs);
1026
1004
 
1027
1005
  const graph = generateKnowledgeGraph({
1028
1006
  projectId: kgProjectId,
1029
1007
  observations: allObs,
1030
1008
  miniSkills: skills,
1031
- graphEntities: scopedEntities,
1032
- graphRelations: scopedRelations,
1009
+ graphEntities: scoped.entities,
1010
+ graphRelations: scoped.relations,
1033
1011
  });
1034
1012
 
1035
1013
  sendJson(graph);
@@ -1369,19 +1347,14 @@ export default defineCommand({
1369
1347
  const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
1370
1348
  await initGraphStore(expDataDir);
1371
1349
  const fullGraph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
1372
- const observations = await loadDashboardProjectObservations(expDataDir, expProjectId, 'active') as Array<{ entityName?: string }>;
1350
+ const observations = await loadDashboardProjectObservations(expDataDir, expProjectId, 'active');
1373
1351
  const expStore = await getDashboardObservationStore(expDataDir);
1374
1352
  const nextId = await expStore.loadIdCounter();
1375
- const exportEntityNames = new Set(
1376
- observations.filter(o => o.entityName).map(o => o.entityName!),
1377
- );
1378
- const exportEntities = fullGraph.entities.filter((e: any) => exportEntityNames.has(e.name));
1379
- const exportEntitySet = new Set(exportEntities.map((e: any) => e.name));
1380
- const exportRelations = fullGraph.relations.filter((r: any) => exportEntitySet.has(r.from) && exportEntitySet.has(r.to));
1353
+ const scoped = scopeKnowledgeGraphToProject(fullGraph, observations);
1381
1354
  const exportData = {
1382
1355
  project: { id: expProjectId, name: expProjectName },
1383
1356
  exportedAt: new Date().toISOString(),
1384
- graph: { entities: exportEntities, relations: exportRelations },
1357
+ graph: { entities: scoped.entities, relations: scoped.relations },
1385
1358
  observations,
1386
1359
  nextId,
1387
1360
  };
@@ -1062,7 +1062,7 @@ function tryInstallOmpPackage(packagePath: string): { ok: boolean; message: stri
1062
1062
  };
1063
1063
  }
1064
1064
 
1065
- async function installAgentSetup(agent: AgentName, plan: SetupPlan, global: boolean): Promise<void> {
1065
+ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, global: boolean): Promise<void> {
1066
1066
  const { installAgentGuidance, installHooks } = await import('../../hooks/installers/index.js');
1067
1067
  const targetRoot = global ? homedir() : process.cwd();
1068
1068
  const hasPluginPackage = plan.actions.includes('plugin-package') && PLUGIN_PACKAGE_AGENTS.has(agent);
@@ -1200,13 +1200,19 @@ async function installAgentSetup(agent: AgentName, plan: SetupPlan, global: bool
1200
1200
  } else if (hasOmpPackage) {
1201
1201
  p.log.info(`${agent}: extension command, hook events, and skills are bundled in the Oh-my-Pi package`);
1202
1202
  } else if (hasExtensionPackage) {
1203
- if (plan.actions.includes('hooks') || plan.actions.includes('project-guidance')) {
1203
+ if (plan.actions.includes('hooks')) {
1204
1204
  const result = await installHooks(agent, targetRoot, global);
1205
1205
  p.log.success(`${agent}: integration files -> ${result.configPath}`);
1206
+ } else if (plan.actions.includes('project-guidance')) {
1207
+ const rulesPath = await installAgentGuidance(agent, targetRoot, global);
1208
+ p.log.success(`${agent}: guidance -> ${rulesPath}`);
1206
1209
  }
1207
- } else if (agent === 'opencode' || plan.actions.includes('hooks') || plan.actions.includes('project-guidance')) {
1210
+ } else if (plan.actions.includes('hooks')) {
1208
1211
  const result = await installHooks(agent, targetRoot, global);
1209
1212
  p.log.success(`${agent}: integration files -> ${result.configPath}`);
1213
+ } else if (plan.actions.includes('project-guidance')) {
1214
+ const rulesPath = await installAgentGuidance(agent, targetRoot, global);
1215
+ p.log.success(`${agent}: guidance -> ${rulesPath}`);
1210
1216
  }
1211
1217
 
1212
1218
  if (agent === 'pi') {
@@ -4,7 +4,7 @@ import { getResolvedConfig } from '../config/resolved-config.js';
4
4
  import { buildTaskWorkset, type TaskWorkset, type WorksetCaution } from '../knowledge/workset.js';
5
5
  import type { ProjectInfo } from '../types.js';
6
6
  import { backfillMissingObservationCodeRefs, type CodeRefBackfillResult } from './binder.js';
7
- import { collectCurrentProjectFacts, type CurrentProjectFacts } from './current-facts.js';
7
+ import { collectCurrentProjectFacts, formatGitFact, type CurrentProjectFacts } from './current-facts.js';
8
8
  import { refreshProjectLite } from './lite-provider.js';
9
9
  import {
10
10
  getExternalCodeGraphContext,
@@ -430,15 +430,7 @@ function formatCurrentFactsLines(facts: CurrentProjectFacts): string[] {
430
430
  lines.push(`- Latest changelog: ${facts.latestChangelog.version}${facts.latestChangelog.date ? ` (${facts.latestChangelog.date})` : ''}`);
431
431
  }
432
432
 
433
- const gitParts: string[] = [];
434
- if (facts.git.detached) {
435
- gitParts.push('detached HEAD');
436
- } else if (facts.git.branch) {
437
- gitParts.push(`branch ${facts.git.branch}`);
438
- }
439
- if (facts.git.commit) gitParts.push(`commit ${facts.git.commit}`);
440
- gitParts.push(facts.git.dirty ? 'dirty worktree' : 'clean worktree');
441
- lines.push(`- Git: ${gitParts.join(', ')}`);
433
+ lines.push('- ' + formatGitFact(facts.git));
442
434
  if (facts.git.latestCommit) lines.push(`- Latest commit: ${facts.git.latestCommit}`);
443
435
  lines.push('- Current facts above outrank progress/dev-log files when they conflict.');
444
436
 
@@ -464,11 +456,7 @@ function worksetFactLines(facts: CurrentProjectFacts): string[] {
464
456
  lines.push('Latest changelog: ' + facts.latestChangelog.version
465
457
  + (facts.latestChangelog.date ? ' (' + facts.latestChangelog.date + ')' : ''));
466
458
  }
467
- const gitParts: string[] = [];
468
- if (facts.git.branch) gitParts.push('branch ' + facts.git.branch);
469
- if (facts.git.commit) gitParts.push('commit ' + facts.git.commit);
470
- gitParts.push(facts.git.dirty ? 'dirty worktree' : 'clean worktree');
471
- lines.push('Git: ' + gitParts.join(', '));
459
+ lines.push(formatGitFact(facts.git));
472
460
  for (const note of facts.staleNotes.slice(0, 1)) {
473
461
  lines.push(
474
462
  'Historical note: ' + note.path
@@ -10,6 +10,7 @@ export interface CurrentProjectFacts {
10
10
  date?: string;
11
11
  };
12
12
  git: {
13
+ available: boolean;
13
14
  branch?: string;
14
15
  commit?: string;
15
16
  latestCommit?: string;
@@ -72,11 +73,18 @@ function runGit(rootPath: string, args: string[]): string | undefined {
72
73
  }
73
74
 
74
75
  function readGitFacts(rootPath: string): CurrentProjectFacts['git'] {
75
- const branch = runGit(rootPath, ['branch', '--show-current']);
76
+ const available = runGit(rootPath, ['rev-parse', '--is-inside-work-tree']) === 'true';
77
+ if (!available) {
78
+ return { available: false, dirty: false, detached: false };
79
+ }
80
+
81
+ const branch = runGit(rootPath, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
82
+ ?? runGit(rootPath, ['branch', '--show-current']);
76
83
  const commit = runGit(rootPath, ['rev-parse', '--short', 'HEAD']);
77
84
  const latestCommit = runGit(rootPath, ['log', '-1', '--pretty=%s']);
78
85
  const dirty = Boolean(runGit(rootPath, ['status', '--porcelain']));
79
86
  return {
87
+ available: true,
80
88
  ...(branch ? { branch } : {}),
81
89
  ...(commit ? { commit } : {}),
82
90
  ...(latestCommit ? { latestCommit } : {}),
@@ -85,6 +93,16 @@ function readGitFacts(rootPath: string): CurrentProjectFacts['git'] {
85
93
  };
86
94
  }
87
95
 
96
+ export function formatGitFact(git: CurrentProjectFacts['git']): string {
97
+ if (!git.available) return 'Git: unavailable';
98
+ const parts: string[] = [];
99
+ if (git.detached) parts.push('detached HEAD');
100
+ else if (git.branch) parts.push('branch ' + git.branch);
101
+ if (git.commit) parts.push('commit ' + git.commit);
102
+ parts.push(git.dirty ? 'dirty worktree' : 'clean worktree');
103
+ return 'Git: ' + parts.join(', ');
104
+ }
105
+
88
106
  function parseProgressNote(content: string): Pick<ProjectStaleNote, 'lastUpdated' | 'branchHint'> {
89
107
  const lastUpdated = content.match(/Last updated\*\*:\s*(\d{4}-\d{2}-\d{2})/i)
90
108
  ?? content.match(/Last updated:\s*(\d{4}-\d{2}-\d{2})/i);
@@ -115,6 +115,7 @@ const KEYWORDS: Record<Exclude<TaskLensId, 'general'>, string[]> = {
115
115
  bugfix: [
116
116
  'bug',
117
117
  'crash',
118
+ 'incident',
118
119
  'debug',
119
120
  'error',
120
121
  'fail',
@@ -125,6 +126,7 @@ const KEYWORDS: Record<Exclude<TaskLensId, 'general'>, string[]> = {
125
126
  'repro',
126
127
  '报错',
127
128
  '崩溃',
129
+ '故障',
128
130
  '失败',
129
131
  '修复',
130
132
  '问题',
@@ -157,11 +159,53 @@ function tokenize(text: string): string[] {
157
159
  .filter(token => token.length > 1 && !STOP_WORDS.has(token));
158
160
  }
159
161
 
160
- function containsKeyword(text: string, keyword: string): boolean {
161
- if (/^[a-z0-9_-]+$/i.test(keyword)) {
162
- return new RegExp(`(^|[^a-z0-9_-])${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_-]|$)`, 'i').test(text);
162
+ /**
163
+ * Match a task keyword only when the task states it as an action or intent,
164
+ * rather than prohibiting that action. Workflow selection shares this rule so
165
+ * a safety constraint cannot reintroduce an intent rejected by task routing.
166
+ */
167
+ export function containsTaskKeyword(text: string, keyword: string): boolean {
168
+ const matcher = /^[a-z0-9_-]+$/i.test(keyword)
169
+ ? new RegExp(`(^|[^a-z0-9_-])${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[^a-z0-9_-]|$)`, 'gi')
170
+ : new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
171
+ let match: RegExpExecArray | null;
172
+ while ((match = matcher.exec(text)) !== null) {
173
+ const keywordIndex = match.index + (match[1]?.length ?? 0);
174
+ if (!isNegatedTaskKeyword(text, keywordIndex)) return true;
163
175
  }
164
- return text.includes(keyword);
176
+ return false;
177
+ }
178
+
179
+ /**
180
+ * Task text often carries safety constraints such as "do not publish". Those
181
+ * constraints must not route a debugging task into the release workflow.
182
+ * Keep this deliberately small and local: it only suppresses a keyword inside
183
+ * the same sentence when the sentence explicitly negates an action.
184
+ */
185
+ function isNegatedTaskKeyword(text: string, keywordIndex: number): boolean {
186
+ const boundary = Math.max(
187
+ text.lastIndexOf('.', keywordIndex - 1),
188
+ text.lastIndexOf('!', keywordIndex - 1),
189
+ text.lastIndexOf('?', keywordIndex - 1),
190
+ text.lastIndexOf(';', keywordIndex - 1),
191
+ text.lastIndexOf('\n', keywordIndex - 1),
192
+ text.lastIndexOf('\u3002', keywordIndex - 1),
193
+ text.lastIndexOf('\uff01', keywordIndex - 1),
194
+ text.lastIndexOf('\uff1f', keywordIndex - 1),
195
+ text.lastIndexOf('\uff1b', keywordIndex - 1),
196
+ );
197
+ let prefix = text.slice(boundary + 1, keywordIndex).toLowerCase();
198
+ const contrast = /(?:,|\uff0c)\s*(?:but|however|instead|yet|\u4f46\u662f|\u4f46|\u800c\u662f)\s*/gi;
199
+ let contrastMatch: RegExpExecArray | null;
200
+ let contrastEnd = -1;
201
+ while ((contrastMatch = contrast.exec(prefix)) !== null) contrastEnd = contrast.lastIndex;
202
+ if (contrastEnd >= 0) prefix = prefix.slice(contrastEnd);
203
+ if (/\b(?:do|does|did|should|must|can|could|will|would|may|might)\s+not\b/.test(prefix)) return true;
204
+ if (/\b(?:don't|dont|never|without|avoid|skip)\b/.test(prefix)) return true;
205
+ if (/(?:^|[\s,])no\s+(?:[a-z0-9_-]+\s*){0,4}$/i.test(prefix)) return true;
206
+ const chineseClauseStart = Math.max(prefix.lastIndexOf(','), prefix.lastIndexOf('\uff0c'), prefix.lastIndexOf('\u3001'));
207
+ const chineseClause = prefix.slice(chineseClauseStart + 1);
208
+ return /(?:\u4e0d\u8981|\u4e0d\u5e94|\u4e0d\u53ef|\u4e0d\u80fd|\u4e0d\u4f1a|\u7981\u6b62|\u52ff|\u4e0d)\s*(?:\u7acb\u5373|\u76f4\u63a5|\u518d|\u73b0\u5728|\u64c5\u81ea|\u5148)?\s*$/.test(chineseClause);
165
209
  }
166
210
 
167
211
  export function resolveTaskLens(task?: string): TaskLens {
@@ -175,7 +219,7 @@ export function resolveTaskLens(task?: string): TaskLens {
175
219
  };
176
220
 
177
221
  for (const id of LENS_PRIORITY) {
178
- const score = KEYWORDS[id].reduce((sum, keyword) => sum + (containsKeyword(normalized, keyword) ? 1 : 0), 0);
222
+ const score = KEYWORDS[id].reduce((sum, keyword) => sum + (containsTaskKeyword(normalized, keyword) ? 1 : 0), 0);
179
223
  const priority = LENS_PRIORITY.indexOf(id);
180
224
  if (score > best.score || (score === best.score && score > 0 && priority < best.priority)) {
181
225
  best = { id, score, priority };